---
openapi: 3.0.3
info:
  title: MangoApps API
  description: "# MangoApps API Documentation\n\nThis API provides access to the MangoApps
    platform, allowing you to:\n- Manage shifts and schedules\n- Get notified of changes
    via webhooks\n- Upload and retrieve demand forecasts\n- Perform bulk operations
    on shifts, assignments, and more\n- Mobile authentication and user management\n\n##
    Authentication\n\nThe API supports multiple authentication methods:\n\n### Mobile
    App Authentication\n```\nPOST /api/v1/auth/login\n{\n  \"email\": \"user@example.com\",\n
    \ \"password\": \"password\",\n  \"otp_code\": \"123456\" // Optional, required
    if 2FA is enabled\n}\n```\n\nResponse includes `access_token` and `refresh_token`
    for subsequent requests.\n\n### API Token Authentication\nInclude the token in
    the Authorization header:\n```\nAuthorization: Bearer YOUR_API_TOKEN\n```\n\n###
    Service Account Authentication\nFor service accounts, include both token and secret:\n```\nAuthorization:
    Bearer YOUR_TOKEN\nX-API-Secret: YOUR_SECRET\n```\n\nService accounts with the
    `admin` scope have full access to all API endpoints and bypass\nindividual scope
    checks. Other service accounts require specific scopes (e.g., `read:shifts`,\n`write:attendance`)
    for each endpoint.\n\n### Impersonation Token Authentication\nService accounts
    with `impersonate` or `admin` scope can create short-lived impersonation\ntokens
    to act on behalf of specific users. This is useful for accessing user-specific
    data\n(e.g., leave balances, personal preferences) without creating separate admin
    endpoints.\n\nTo use impersonation:\n1. Create an impersonation token using your
    service account:\n   ```\n   POST /api/v1/auth/impersonate\n   Authorization:
    Bearer YOUR_SERVICE_ACCOUNT_TOKEN\n   X-API-Secret: YOUR_SERVICE_ACCOUNT_SECRET\n
    \  {\n     \"user_id\": 123\n   }\n   ```\n2. Use the returned impersonation token
    for subsequent API calls:\n   ```\n   Authorization: Bearer IMPERSONATION_TOKEN\n
    \  ```\n\nImpersonation tokens:\n- Are short-lived (default: 1 hour)\n- Can be
    revoked at any time\n- Automatically log all access for audit purposes\n- Allow
    access to user-specific endpoints as if authenticated as that user\n\n## Rate
    Limiting\nAPI requests are limited to 100 requests per minute per API token.\nRate
    limit headers are included in responses:\n- `X-RateLimit-Limit`: Request limit
    per window\n- `X-RateLimit-Remaining`: Requests remaining in current window  \n-
    `X-RateLimit-Reset`: Unix timestamp when window resets\n\n## Pagination\nCollections
    return pagination metadata and headers:\n- Query parameters: `page`, `per_page`\n-
    Response headers: `X-Total-Count`, `X-Total-Pages`, `X-Current-Page`, `X-Per-Page`\n\n##
    Error Handling\nErrors follow a consistent format:\n```json\n{\n  \"error\": {\n
    \   \"code\": \"error_code\",\n    \"message\": \"Human readable message\",\n
    \   \"details\": {} // Optional additional context\n  }\n}\n```\n\nValidation
    errors return:\n```json\n{\n  \"errors\": [\n    {\n      \"field\": \"email\",\n
    \     \"message\": \"Email is required\",\n      \"code\": \"validation_error\"\n
    \   }\n  ]\n}\n```\n\n## Piggyback Response System\n\nThe API supports a piggyback
    response system that allows including additional contextual data \nalongside the
    primary response. This reduces network round-trips by proactively sending data
    \nthe client might need next.\n\n### Requesting Piggyback Data\n\nUse the `include`
    query parameter to request specific piggyback data:\n```\nGET /api/v1/users/me?include=business_settings,user_preferences,upcoming_shifts\n```\n\nAvailable
    piggyback data types:\n- `business_settings` - Business configuration and branding\n-
    `user_preferences` - User settings and preferences  \n- `recent_shifts` - Last
    5 completed shifts\n- `upcoming_shifts` - Next 10 scheduled shifts\n- `notifications`
    - Unread notifications (last 20)\n- `business_stats` - Business statistics (admin/manager
    only)\n- `feature_flags` - Enabled feature flags\n- `system_status` - System status
    and announcements\n\n### Alternative Request Methods\n\nUsing headers:\n```\nX-Include-Piggyback:
    business_settings,user_preferences\n```\n\nUsing POST body:\n```json\n{\n  \"email\":
    \"user@example.com\",\n  \"password\": \"password\",\n  \"_include\": {\n    \"business_settings\":
    true,\n    \"user_preferences\": true\n  }\n}\n```\n\n### Server Recommendations\n\nFor
    mobile apps, the server automatically includes recommended data for login and
    profile \nrequests to optimize the user experience. Server recommendations include:\n-
    Suggested next actions based on user context\n- Setup reminders for incomplete
    profiles\n- Important notifications requiring attention\n- App usage tips and
    feature discovery\n\n### Piggyback Response Structure\n\nWhen piggyback data is
    included, responses contain an additional `_meta` section:\n```json\n{\n  \"user\":
    { /* primary response data */ },\n  \"_meta\": {\n    \"piggyback\": {\n      \"business_settings\":
    { /* business configuration */ },\n      \"user_preferences\": { /* user settings
    */ },\n      \"upcoming_shifts\": [ /* shift array */ ]\n    },\n    \"query_info\":
    {\n      \"included_data\": [\"business_settings\", \"user_preferences\", \"upcoming_shifts\"],\n
    \     \"execution_time_ms\": 45.2,\n      \"cache_hits\": 2,\n      \"cache_misses\":
    1\n    }\n  }\n}\n```\n\n## HTTP Protocol Enhancements\n\nThe API includes comprehensive
    HTTP protocol enhancements for optimal client performance:\n\n### Intelligent
    Caching\n- **ETags**: Content-based versioning for efficient cache validation\n-
    **Cache-Control**: Fine-grained cache behavior control\n- **Conditional Requests**:
    Support for If-None-Match headers (304 responses)\n- **Vary Headers**: Content
    negotiation awareness\n\n### Dynamic Navigation\n- **Next Actions**: Server-suggested
    user actions based on context\n- **Preload Hints**: Resources to preload for better
    performance\n- **Conditional Redirects**: Dynamic flow control based on user state\n-
    **Link Relations**: Standard HTTP Link headers for navigation\n\n### Performance
    Optimization\n- **Response Compression**: Automatic gzip/deflate compression\n-
    **Response Timing**: Server processing metrics\n- **Cache Status**: Hit/miss reporting
    for optimization\n- **Size Estimation**: Bandwidth usage awareness\n\n### Base
    Path and Versioning\n- All endpoint paths in this specification are relative.
    The `/api/v1` base path is provided by the `servers` section below.\n- When calling
    the API, combine the server URL with the relative path. For example: `https://dev.workforce.mangoapps.com/api/v1`
    + `/shifts` → `https://dev.workforce.mangoapps.com/api/v1/shifts`.\n- Do not include
    `/api/v1` in individual path keys; it is already defined at the server level for
    consistency across environments.\n\n### Client Hints & Reliability\n- **Rate Limiting**:
    Request quota and reset information (429 status)\n- **API Versioning**: Version
    compatibility information\n- **Deprecation Warnings**: Graceful API evolution
    notices\n- **Capability Detection**: Client feature detection\n- **Idempotency
    Support**: Duplicate request prevention with Idempotency-Key\n- **Conditional
    Requests**: 304 Not Modified for cached content\n\n### Example Enhanced Response
    Headers\n```http\nHTTP/1.1 200 OK\nContent-Type: application/json; charset=utf-8\nContent-Encoding:
    gzip\nETag: W/\"abc123-def456\"\nCache-Control: private, max-age=300, must-revalidate\nVary:
    Accept, Authorization, X-App-Platform\nLink: </api/v1/shifts?status=upcoming>;
    rel=\"preload\"; as=\"fetch\"\nX-Response-Time: 45.2ms\nX-Rate-Limit-Remaining:
    98\nX-Rate-Limit-Reset: 1640995200\nX-Rate-Limit-Limit: 100\nX-Idempotency-Cached:
    false\nX-API-Version: 1.0\n```\n\nThese enhancements work seamlessly with the
    piggyback response system to provide:\n- **50-80% bandwidth reduction** with compression\n-
    **60-90% cache hit rates** with intelligent ETags\n- **2-3x faster navigation**
    with preload hints\n- **Reduced API calls** with conditional requests\n"
  version: 1.0.0
  contact:
    name: MangoApps API Support
    email: api-support@workforce.mangoapps.com
  license:
    name: Proprietary
servers:
- url: https://api.workforce.mangoapps.com/api/v1
  description: Production - Common API
- url: https://dev.workforce.mangoapps.com/api/v1
  description: Development - Common API
- url: https://{business}.workforce.mangoapps.com/api/v1
  description: Production - Business API
  variables:
    business:
      default: demo
      description: Business subdomain (e.g., officechat, acmecorp)
      examples:
      - officechat
      - acmecorp
      - retailstore
- url: https://{business}.dev.workforce.mangoapps.com/api/v1
  description: Development - Business API
  variables:
    business:
      default: demo
      description: Business subdomain for development
security:
- BearerAuth: []
- ServiceAccountAuth: []
paths:
  "/changelog_entries":
    get:
      tags:
      - Changelog
      summary: List changelog entries
      description: "Retrieve a list of changelog entries. This endpoint is for internal
        use only \nand requires either internal API credentials or a changelog secret.\n"
      security:
      - InternalAPI: []
      - ChangelogSecret: []
      responses:
        '200':
          description: List of changelog entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChangelogEntry"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 25
                      published_count:
                        type: integer
                        example: 20
                      latest_version:
                        type: string
                        example: '1.6'
        '401':
          "$ref": "#/components/responses/Unauthorized"
    post:
      tags:
      - Changelog
      summary: Create changelog entry
      description: "Create a new changelog entry. This endpoint is for internal use
        only \nand requires either internal API credentials or a changelog secret.\n"
      security:
      - InternalAPI: []
      - ChangelogSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - changelog_entry
              properties:
                changelog_entry:
                  "$ref": "#/components/schemas/ChangelogEntryInput"
      responses:
        '201':
          description: Changelog entry created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Changelog entry created successfully
                  data:
                    "$ref": "#/components/schemas/ChangelogEntryResponse"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  message:
                    type: string
                    example: Failed to create changelog entry
                  errors:
                    type: array
                    items:
                      type: string
                    example:
                    - Title can't be blank
                    - Content can't be blank
  "/actioncue/webhooks/job_status/{job_uuid}":
    post:
      tags:
      - ActionCue Webhooks
      summary: Update job status via webhook
      description: |
        External processing systems use this endpoint to send job status updates.
        Requires HMAC-SHA256 signature for authentication.
      security: []
      parameters:
      - name: job_uuid
        in: path
        required: true
        description: Unique identifier for the processing job
        schema:
          type: string
          example: 550e8400-e29b-41d4-a716-446655440000
      - name: X-ActionCue-Signature
        in: header
        required: true
        description: HMAC-SHA256 signature of request body
        schema:
          type: string
          example: sha256=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - pending
                  - processing
                  - completed
                  - failed
                  - cancelled
                  description: Current job status
                  example: completed
                progress:
                  type: number
                  minimum: 0
                  maximum: 100
                  description: Processing progress percentage
                  example: 95.5
                confidence_score:
                  type: number
                  minimum: 0
                  maximum: 1
                  description: Overall confidence score
                  example: 0.85
                error_message:
                  type: string
                  description: Error message if status is failed
                  example: Invalid file format
                completed_at:
                  type: string
                  format: date-time
                  description: Completion timestamp
                  example: '2024-01-15T10:30:00Z'
                started_at:
                  type: string
                  format: date-time
                  description: Processing start timestamp
                  example: '2024-01-15T10:25:00Z'
                processing_time_ms:
                  type: integer
                  description: Processing time in milliseconds
                  example: 45000
                extracted_results:
                  type: array
                  description: Extracted form field data
                  items:
                    type: object
                    properties:
                      field_id:
                        type: string
                        example: first_name
                      field_name:
                        type: string
                        example: First Name
                      extracted_value:
                        type: string
                        example: John
                      confidence:
                        type: number
                        minimum: 0
                        maximum: 1
                        example: 0.95
                metadata:
                  type: object
                  description: Additional metadata from external system
                  additionalProperties: true
      responses:
        '200':
          description: Status updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: success
                  message:
                    type: string
                    example: Job status updated successfully
                  job_uuid:
                    type: string
                    example: 550e8400-e29b-41d4-a716-446655440000
        '401':
          description: Authentication failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: error
                  message:
                    type: string
                    example: Invalid webhook signature
        '404':
          description: Job not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: error
                  message:
                    type: string
                    example: Processing job not found
                  job_uuid:
                    type: string
                    example: 550e8400-e29b-41d4-a716-446655440000
        '422':
          description: Invalid request data
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: error
                  message:
                    type: string
                    example: 'Configuration error: Status is required'
  "/actioncue/webhooks/batch_status":
    post:
      tags:
      - ActionCue Webhooks
      summary: Update multiple job statuses via webhook
      description: |
        External processing systems use this endpoint to send batch job status updates.
        Requires HMAC-SHA256 signature for authentication.
      security: []
      parameters:
      - name: X-ActionCue-Signature
        in: header
        required: true
        description: HMAC-SHA256 signature of request body
        schema:
          type: string
          example: sha256=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - jobs
              properties:
                jobs:
                  type: array
                  description: Array of job status updates
                  items:
                    type: object
                    required:
                    - job_uuid
                    - status
                    properties:
                      job_uuid:
                        type: string
                        example: 550e8400-e29b-41d4-a716-446655440000
                      status:
                        type: string
                        enum:
                        - pending
                        - processing
                        - completed
                        - failed
                        - cancelled
                        example: completed
                      progress:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 100
                      confidence_score:
                        type: number
                        minimum: 0
                        maximum: 1
                        example: 0.92
                      error_message:
                        type: string
                        example:
                      completed_at:
                        type: string
                        format: date-time
                        example: '2024-01-15T10:30:00Z'
                      processing_time_ms:
                        type: integer
                        example: 45000
                      extracted_results:
                        type: array
                        items:
                          type: object
                          properties:
                            field_id:
                              type: string
                              example: email
                            extracted_value:
                              type: string
                              example: john@example.com
                            confidence:
                              type: number
                              example: 0.98
      responses:
        '200':
          description: Batch update processed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: success
                  message:
                    type: string
                    example: Batch update processed
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        job_uuid:
                          type: string
                          example: 550e8400-e29b-41d4-a716-446655440000
                        status:
                          type: string
                          example: success
                        message:
                          type: string
                          example: Updated
  "/actioncue/webhooks/health":
    get:
      tags:
      - ActionCue Webhooks
      summary: Health check for webhook endpoints
      description: |
        External systems can use this endpoint to verify webhook availability.
        No authentication required for health checks.
      security: []
      responses:
        '200':
          description: Webhook service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  timestamp:
                    type: string
                    format: date-time
                    example: '2024-01-15T10:30:00Z'
                  version:
                    type: string
                    example: '1.0'
                  service:
                    type: string
                    example: ActionCue Webhooks
  "/businesses/by_email":
    post:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Get businesses by user email
      description: |
        Find the tenants a user's email address can sign in to. This backs the
        workspace picker after mobile's email-entry screen, and is the JSON twin
        of the web `POST /find_companies` flow.

        Prefer POST over the GET below: the query-string form writes the address
        into access logs and error-tracker breadcrumbs.

        Throttled at 20 requests/minute per IP and 10/minute per email address.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              properties:
                email:
                  type: string
                  format: email
                  example: user@officechat.com
      responses:
        '200':
          description: |
            Lookup completed. An empty `businesses` array means the address is
            unknown OR has no active tenants — the two are not distinguished.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BusinessDiscoveryResult"
        '400':
          description: Missing or blank email
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate limit exceeded
    get:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Get businesses by user email (query-string form)
      deprecated: true
      description: |
        Kept for existing clients. Use `POST /businesses/by_email` instead — a
        query string puts the email address into access logs and error-tracker
        breadcrumbs. Identical behaviour and response otherwise.
      security: []
      parameters:
      - name: email
        in: query
        required: true
        description: User's email address
        schema:
          type: string
          format: email
          example: user@officechat.com
      responses:
        '200':
          description: |
            Lookup completed. An empty `businesses` array means the address is
            unknown OR has no active tenants — the two are not distinguished.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BusinessDiscoveryResult"
        '400':
          description: Missing or blank email
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate limit exceeded
  "/businesses/lookup":
    get:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Search businesses by company name
      description: |
        Search for businesses using partial or full company name matching.
        Returns up to 10 matching businesses. Matching ignores case AND
        punctuation on both sides — spaces, apostrophes, hyphens, periods —
        so `Oreilly`, `O'Reilly` and `oreilly auto parts` all find
        "O'Reilly Auto Parts". A term containing no letter or digit at all
        (e.g. `'''`) is rejected with `missing_company_name`, exactly like an
        absent parameter. Useful for company name lookup and business
        directory features.
      security: []
      parameters:
      - name: company_name
        in: query
        required: true
        description: Full or partial company name to search for
        schema:
          type: string
          example: Office Chat
      responses:
        '200':
          description: Businesses found
          content:
            application/json:
              schema:
                type: object
                properties:
                  businesses:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        name:
                          type: string
                          example: Office Chat Solutions
                        subdomain:
                          type: string
                          example: officechat
                        logo_url:
                          type: string
                          nullable: true
                          description: |
                            Absolute logo URL, or null when unattached. Served from
                            the matched tenant's OWN host — this endpoint searches
                            across tenants, so it is not the requesting host.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/logo.png
                        api_base_url:
                          type: string
                          description: |
                            Origin the client should send the subsequent login request
                            to, and the disambiguator to show under each company name
                            in a search list. Identical to the `api_base_url` that
                            `/businesses/by_email` returns for the same business `id`
                            — both endpoints render through one serializer. Already
                            carries the environment suffix (-dev / -qa / -staging;
                            none in production), so clients must not rebuild the host
                            from `subdomain` themselves.
                          example: https://officechat.workforce.mangoapps.com
                        timezone:
                          type: string
                          example: America/New_York
                        industry:
                          type: string
                          description: |
                            Tenant industry, or an empty string when unset — it is
                            emitted as "" rather than omitted, so treat empty as
                            absent. Not a reliable subtitle; use `api_base_url`.
                          example: ''
                  total_count:
                    type: integer
                    example: 2
                    description: Number of businesses found (max 10)
                  search_term:
                    type: string
                    example: Office Chat
                    description: The search term used
        '400':
          description: Missing company name parameter
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No businesses found matching the search term
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/businesses/validate_subdomain":
    get:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Validate business subdomain exists
      description: Check if a business subdomain is available and valid
      security: []
      parameters:
      - name: subdomain
        in: query
        required: true
        description: Business subdomain to validate
        schema:
          type: string
          example: officechat
      responses:
        '200':
          description: Validation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  exists:
                    type: boolean
                    example: true
                  subdomain:
                    type: string
                    example: officechat
                  business_name:
                    type: string
                    example: Office Chat Solutions
        '400':
          description: Missing subdomain parameter
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/login":
    post:
      tags:
      - Authentication
      summary: Login for mobile apps
      description: "Authenticate user and return access/refresh tokens.\n\n**Session
        Cookie Support:** In addition to returning API tokens, this endpoint \nalso
        establishes a Rails session and sets a session cookie (`_workforce_session_*`).
        \nThis allows the session cookie to be used for web page access without requiring
        \na separate web login. The session cookie is automatically set by the server
        and \nmanaged by HTTP clients.\n\n**Use Cases:**\n- Mobile apps: Use the access_token
        for API calls\n- Web views in mobile apps: The session cookie enables seamless
        web page access\n- Hybrid apps: Use either API tokens or session cookie as
        needed\n"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              - password
              properties:
                email:
                  type: string
                  format: email
                  example: user@example.com
                password:
                  type: string
                  format: password
                  example: password123
                otp_code:
                  type: string
                  description: Two-factor authentication code (required if 2FA enabled)
                  example: '123456'
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                    description: JWT access token
                  refresh_token:
                    type: string
                    description: Refresh token for getting new access tokens
                  expires_in:
                    type: integer
                    description: Access token expiry in seconds
                  token_type:
                    type: string
                    example: Bearer
                  user:
                    "$ref": "#/components/schemas/User"
                  business:
                    "$ref": "#/components/schemas/Business"
                  _meta:
                    "$ref": "#/components/schemas/PiggybackMeta"
        '401':
          description: Authentication failed
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          enum:
                          - two_factor_required
                          - invalid_otp
                          - invalid_credentials
                        message:
                          type: string
                        details:
                          type: object
                          properties:
                            requires_2fa:
                              type: boolean
  "/auth/login_config":
    get:
      tags:
      - Authentication
      - Mobile
      summary: Get login configuration for business
      description: |
        Returns complete login configuration for a business including available
        authentication methods, SSO providers, security settings, and API endpoints.
        This endpoint provides all information needed to render a mobile login screen.

        SSO Provider Support:
        - Multiple SSO providers of the same type are supported (e.g., multiple Google OAuth2 configs)
        - Each provider has a unique ID that should be used as the `provider_id` parameter
        - Use `channel_support` and `mobile_supported`/`web_supported` to filter providers appropriate for the client
      security: []
      parameters:
      - name: business_id
        in: query
        required: true
        schema:
          type: integer
        description: Business ID to get login configuration for
        example: 123
      responses:
        '200':
          description: Login configuration retrieved successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoginConfiguration"
        '404':
          description: Business not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/sso/initiate":
    post:
      tags:
      - Authentication
      - SSO
      - Mobile
      summary: Initiate SSO authentication flow
      description: |
        Initiates SSO authentication flow for mobile applications. Returns an authorization
        URL that should be opened in a web view or external browser. The mobile app should
        handle the callback and extract the authorization code for token exchange.

        Provider Selection:
        1. Call `/auth/login_config` to get available SSO providers
        2. Display providers to user (multiple providers of same type may exist)
        3. Use the selected provider's `id` as the `provider_id` in this request
        4. For mobile, choose providers with `mobile_supported: true`
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - business_id
              - provider_id
              properties:
                business_id:
                  type: integer
                  description: Business ID for SSO authentication
                  example: 123
                provider_id:
                  type: string
                  description: SSO provider ID from login configuration
                  example: '2'
                redirect_uri:
                  type: string
                  description: Optional redirect URI (HTTPS URL for Universal Links/App
                    Links - Google OAuth does not support custom URL schemes)
                  example: https://workforce.mangoapps.com/mobile/sso-callback
                app_redirect_uri:
                  type: string
                  description: |
                    Desktop only. Where the callback page sends the browser once the IdP is
                    finished — never sent to the IdP itself. Accepted forms are a registered
                    custom scheme (`mangoappsdesktop://`, `mangoappsmessengerschema://`) or a
                    loopback callback the app serves itself: `http://127.0.0.1:<port>/sso-callback`
                    or `http://[::1]:<port>/sso-callback`, with no query, fragment, or credentials
                    and a port in 1-65535. Anything else is dropped. Check the echoed
                    `app_redirect_uri` in the response to see whether the value survived.
                  example: http://127.0.0.1:52341/sso-callback
      responses:
        '200':
          description: SSO flow initiated successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SsoInitiateResponse"
        '400':
          description: Invalid request parameters
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Business or SSO provider not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: SSO provider temporarily unavailable
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/sso/exchange":
    post:
      tags:
      - Authentication
      - SSO
      - Mobile
      summary: Exchange authorization code for access tokens
      description: "Exchanges the authorization code received from SSO provider for
        access and refresh tokens.\nThis completes the SSO authentication flow and
        returns tokens that can be used for API access.\n\n**Session Cookie Support:**
        In addition to returning API tokens, this endpoint \nalso establishes a Rails
        session and sets a session cookie (`_workforce_session_*`). \nThis allows
        the session cookie to be used for web page access without requiring \na separate
        web login. The session cookie is automatically set by the server and \nincluded
        in the response headers - no client-side code changes are needed to receive
        it.\n\n**Use cases for session cookie:**\n- Web views in mobile apps: The
        session cookie enables seamless web page access\n- Hybrid apps: Use either
        API tokens or session cookie as needed\n- Native mobile + web integration:
        Access web content without re-login prompts\n"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - business_id
              - provider_id
              - code
              - state
              properties:
                business_id:
                  type: integer
                  description: Business ID for SSO authentication
                  example: 123
                provider_id:
                  type: string
                  description: SSO provider ID from login configuration
                  example: '2'
                code:
                  type: string
                  description: Authorization code from SSO provider callback
                  example: 4/0AX4XfWjE...
                state:
                  type: string
                  description: State parameter from SSO initiate response
                  example: abc123def456
      responses:
        '200':
          description: SSO authentication completed successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SsoExchangeResponse"
        '400':
          description: Invalid authorization code or state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: User not found or inactive
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: User not authorized for this business
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Business or SSO provider not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/refresh":
    post:
      tags:
      - Authentication
      summary: Refresh access token
      description: Get a new access token using refresh token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - refresh_token
              properties:
                refresh_token:
                  type: string
                  description: Valid refresh token
      responses:
        '200':
          description: Token refreshed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                  expires_in:
                    type: integer
                  token_type:
                    type: string
                    example: Bearer
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/logout":
    post:
      tags:
      - Authentication
      summary: Logout and revoke tokens
      description: Revoke current access token and optionally refresh token
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token:
                  type: string
                  description: Refresh token to revoke (optional)
      responses:
        '200':
          description: Logged out successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Logged out successfully
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/profile":
    get:
      tags:
      - Authentication
      summary: Get enhanced user profile
      description: "Retrieve comprehensive user profile including security info, preferences,
        \npermissions, and feature flags. This endpoint provides more detailed \ninformation
        than the basic /users/me endpoint.\n"
      responses:
        '200':
          description: Profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    "$ref": "#/components/schemas/EnhancedUser"
                  business:
                    "$ref": "#/components/schemas/Business"
                  security:
                    "$ref": "#/components/schemas/SecurityInfo"
                  preferences:
                    type: object
                    description: User preferences and settings
                  permissions:
                    type: array
                    items:
                      type: string
                    description: User permissions
                  feature_flags:
                    type: object
                    description: Available feature flags for user
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Authentication
      summary: Update user profile
      description: Update user profile information including preferences and notification
        settings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name:
                  type: string
                  example: John
                last_name:
                  type: string
                  example: Doe
                phone:
                  type: string
                  example: "+1234567890"
                time_zone:
                  type: string
                  example: America/New_York
                preferences:
                  type: object
                  description: User preferences to update
                notification_preferences:
                  type: object
                  description: Notification preferences to update
      responses:
        '200':
          description: Profile updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Profile updated successfully
                  user:
                    "$ref": "#/components/schemas/EnhancedUser"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/auth/change_password":
    post:
      tags:
      - Authentication
      summary: Change user password
      description: |
        Change the current user's password with proper verification.
        Optionally revoke all other sessions for enhanced security.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              - new_password
              - new_password_confirmation
              properties:
                current_password:
                  type: string
                  description: Current password for verification
                new_password:
                  type: string
                  description: New password (minimum 6 characters)
                new_password_confirmation:
                  type: string
                  description: Confirmation of new password
                revoke_other_sessions:
                  type: boolean
                  default: false
                  description: Whether to revoke all other active sessions
      responses:
        '200':
          description: Password changed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Password changed successfully
                  revoked_sessions:
                    type: boolean
                    description: Whether other sessions were revoked
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/auth/password_reset/forgot_password":
    post:
      tags:
      - Authentication
      - Password Reset
      summary: Request password reset
      description: |
        Initiate password reset process by sending a reset link to the user's email.
        Returns a generic success message regardless of whether the email exists (security best practice).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              properties:
                email:
                  type: string
                  format: email
                  description: Email address of the account
                  example: user@example.com
      responses:
        '200':
          description: Password reset email sent (or email doesn't exist - generic
            response for security)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: If an account exists with this email, you will receive
                      password reset instructions shortly.
                  email:
                    type: string
                    example: user@example.com
        '400':
          "$ref": "#/components/responses/BadRequest"
  "/auth/password_reset/validate_token":
    get:
      tags:
      - Authentication
      - Password Reset
      summary: Validate password reset token
      description: |
        Check if a password reset token is valid and not expired.
        Tokens expire after 6 hours.
      security: []
      parameters:
      - name: token
        in: query
        required: true
        schema:
          type: string
        description: Password reset token from email
        example: abc123xyz789
      responses:
        '200':
          description: Token is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: true
                  email:
                    type: string
                    example: user@example.com
                  expires_at:
                    type: string
                    format: date-time
                    example: '2025-10-10T20:00:00Z'
        '401':
          description: Token is invalid or expired
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: invalid_or_expired_token
                  message: Password reset token is invalid or has expired.
  "/auth/password_reset/reset_password":
    post:
      tags:
      - Authentication
      - Password Reset
      summary: Reset password with token
      description: |
        Complete the password reset process using a valid reset token.
        Optionally revoke all existing sessions for enhanced security.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - token
              - password
              - password_confirmation
              properties:
                token:
                  type: string
                  description: Password reset token from email
                  example: abc123xyz789
                password:
                  type: string
                  format: password
                  description: New password (minimum 6 characters)
                  example: newSecurePassword123
                password_confirmation:
                  type: string
                  format: password
                  description: Confirmation of new password
                  example: newSecurePassword123
                revoke_all_sessions:
                  type: boolean
                  default: false
                  description: Whether to revoke all existing sessions after password
                    reset
      responses:
        '200':
          description: Password reset successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Your password has been reset successfully.
                  email:
                    type: string
                    example: user@example.com
                  revoked_all_sessions:
                    type: boolean
                    description: Whether all sessions were revoked
        '400':
          description: Invalid request (passwords don't match, too weak, etc.)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Token is invalid or expired
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/auth/sessions":
    get:
      tags:
      - Authentication
      summary: List active sessions
      description: |
        Retrieve a list of all active sessions (API tokens) for the current user.
        Includes session details like device info, location, and last activity.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Sessions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Session"
                  meta:
                    type: object
                    properties:
                      current_session_id:
                        type: integer
                        description: ID of the current session
                      total_active_sessions:
                        type: integer
                        description: Total number of active sessions
                  pagination:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/sessions/current":
    get:
      tags:
      - Authentication
      summary: Get current session details
      description: Retrieve detailed information about the current active session
      responses:
        '200':
          description: Current session retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  session:
                    "$ref": "#/components/schemas/DetailedSession"
                  is_current_session:
                    type: boolean
                    example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/sessions/revoke_all":
    post:
      tags:
      - Authentication
      summary: Revoke all other sessions
      description: "Revoke all active sessions except the current one. Useful for
        security \nwhen a user suspects their account may be compromised.\n"
      responses:
        '200':
          description: Other sessions revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Successfully revoked 3 other sessions
                  revoked_count:
                    type: integer
                    description: Number of sessions revoked
                  current_session_preserved:
                    type: boolean
                    example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/sessions/{id}":
    get:
      tags:
      - Authentication
      summary: Get session details
      description: Retrieve detailed information about a specific session
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Session ID
      responses:
        '200':
          description: Session details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  session:
                    "$ref": "#/components/schemas/DetailedSession"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
    delete:
      tags:
      - Authentication
      summary: Revoke specific session
      description: "Revoke a specific session by ID. Cannot revoke the current session
        - \nuse the logout endpoint instead.\n"
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Session ID to revoke
      responses:
        '200':
          description: Session revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Session revoked successfully
                  revoked_session:
                    "$ref": "#/components/schemas/Session"
        '400':
          description: Cannot revoke current session
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/2fa/status":
    get:
      tags:
      - Two-Factor Authentication
      summary: Get 2FA status
      description: Retrieve the current two-factor authentication status and configuration
      responses:
        '200':
          description: 2FA status retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  two_factor_enabled:
                    type: boolean
                    description: Whether 2FA is currently enabled
                  two_factor_status:
                    type: string
                    enum:
                    - disabled
                    - pending_setup
                    - pending_confirmation
                    - enabled
                    description: Current 2FA setup status
                  setup_required:
                    type: boolean
                    description: Whether 2FA setup is required by organization policy
                  backup_codes_count:
                    type: integer
                    description: Number of remaining backup codes
                  setup_at:
                    type: string
                    format: date-time
                    description: When 2FA setup was initiated
                  confirmed_at:
                    type: string
                    format: date-time
                    description: When 2FA was confirmed and enabled
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/2fa/setup":
    post:
      tags:
      - Two-Factor Authentication
      summary: Setup 2FA
      description: |
        Initiate two-factor authentication setup. Returns QR code URI and backup codes.
        Requires current password for verification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: 2FA setup initiated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Two-factor authentication setup initiated
                  qr_code_uri:
                    type: string
                    description: QR code URI for authenticator apps
                  secret:
                    type: string
                    description: Secret key for manual entry
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: Backup codes for account recovery
                  next_step:
                    type: string
                    description: Instructions for next step
        '400':
          description: 2FA already enabled or invalid request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/2fa/verify":
    post:
      tags:
      - Two-Factor Authentication
      summary: Verify and enable 2FA
      description: |
        Complete 2FA setup by verifying a code from the authenticator app.
        This enables 2FA for the user account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - code
              properties:
                code:
                  type: string
                  description: 6-digit verification code from authenticator app
                  example: '123456'
      responses:
        '200':
          description: 2FA enabled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Two-factor authentication enabled successfully
                  two_factor_enabled:
                    type: boolean
                    example: true
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: Backup codes for account recovery
        '400':
          description: Invalid code or 2FA not setup
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/2fa/disable":
    post:
      tags:
      - Two-Factor Authentication
      summary: Disable 2FA
      description: |
        Disable two-factor authentication for the user account.
        Requires current password and either a 2FA code or backup code.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
                code:
                  type: string
                  description: 6-digit verification code from authenticator app
                  example: '123456'
                backup_code:
                  type: string
                  description: Backup code (alternative to verification code)
                  example: a1b2c3d4
      responses:
        '200':
          description: 2FA disabled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Two-factor authentication disabled successfully
                  two_factor_enabled:
                    type: boolean
                    example: false
        '400':
          description: 2FA not enabled or invalid request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/2fa/backup_codes":
    get:
      tags:
      - Two-Factor Authentication
      summary: Get backup codes
      description: |
        Retrieve current backup codes for 2FA recovery.
        Requires current password for security.
      parameters:
      - name: current_password
        in: query
        required: true
        schema:
          type: string
        description: Current password for verification
      responses:
        '200':
          description: Backup codes retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: Current backup codes
                  codes_count:
                    type: integer
                    description: Number of backup codes
                  warning:
                    type: string
                    description: Security warning about backup codes
        '400':
          description: 2FA not enabled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/2fa/regenerate_backup_codes":
    post:
      tags:
      - Two-Factor Authentication
      summary: Regenerate backup codes
      description: |
        Generate new backup codes, invalidating all previous codes.
        Requires current password for security.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Backup codes regenerated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Backup codes regenerated successfully
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: New backup codes
                  warning:
                    type: string
                    description: Warning about old codes being invalidated
        '400':
          description: 2FA not enabled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/passwordless/request":
    post:
      tags:
      - Passwordless Authentication
      summary: Request passwordless authentication
      description: |
        Request a passwordless authentication token via email or SMS.
        Supports magic links, email verification codes, and SMS codes.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              properties:
                email:
                  type: string
                  format: email
                  description: User's email address
                  example: user@example.com
                type:
                  type: string
                  enum:
                  - magic_link
                  - verification_code
                  - sms_code
                  default: magic_link
                  description: Type of passwordless authentication
                  example: magic_link
      responses:
        '200':
          description: Authentication request sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Magic link sent to your email address
                  type:
                    type: string
                    example: magic_link
                  expires_in:
                    type: string
                    description: Human-readable expiration time
                    example: 1 hour
                  token_id:
                    type: integer
                    description: Token ID for debugging (development only)
                    example: 123
        '400':
          "$ref": "#/components/responses/BadRequest"
        '422':
          description: No account found or validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/passwordless/verify":
    post:
      tags:
      - Passwordless Authentication
      summary: Verify passwordless authentication token
      description: |
        Verify a passwordless authentication token and receive API access tokens.
        Works with magic link tokens, email codes, and SMS codes.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - token
              properties:
                token:
                  type: string
                  description: Authentication token from email/SMS
                  example: abc123def456
      responses:
        '200':
          description: Authentication successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                    description: API access token
                  refresh_token:
                    type: string
                    description: Token refresh token
                  expires_in:
                    type: integer
                    description: Token expiration in seconds
                    example: 3600
                  token_type:
                    type: string
                    example: Bearer
                  authentication_method:
                    type: string
                    example: passwordless
                  passwordless_type:
                    type: string
                    example: magic_link
                  user:
                    "$ref": "#/components/schemas/User"
                  business:
                    "$ref": "#/components/schemas/Business"
        '401':
          description: Invalid or expired token
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/passwordless/status":
    get:
      tags:
      - Passwordless Authentication
      summary: Get passwordless authentication status
      description: |
        Get information about recent passwordless authentication attempts
        and system configuration.
      responses:
        '200':
          description: Status retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  passwordless_enabled:
                    type: boolean
                    example: true
                  recent_tokens:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PasswordlessToken"
                  supported_types:
                    type: array
                    items:
                      type: string
                    example:
                    - magic_link
                    - verification_code
                    - sms_code
                  rate_limit_info:
                    type: object
                    properties:
                      max_requests_per_5_minutes:
                        type: integer
                        example: 3
                      current_period_requests:
                        type: integer
                        example: 1
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/tokens":
    get:
      tags:
      - API Token Management
      summary: List personal API tokens
      description: |
        Retrieve a list of personal API tokens for the current user.
        Excludes session tokens and service account tokens.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Tokens retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PersonalApiToken"
                  meta:
                    type: object
                    properties:
                      total_tokens:
                        type: integer
                        description: Total number of tokens
                      active_tokens:
                        type: integer
                        description: Number of active tokens
                      available_scopes:
                        type: array
                        items:
                          type: string
                        description: Available scopes for personal tokens
                  pagination:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    post:
      tags:
      - API Token Management
      summary: Create personal API token
      description: |
        Create a new personal API token with specified scopes and expiration.
        Requires current password for security verification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                name:
                  type: string
                  description: Token name/description
                  example: My Automation Token
                  maxLength: 100
                current_password:
                  type: string
                  description: Current password for verification
                scopes:
                  type: array
                  items:
                    type: string
                  description: Requested scopes (filtered to employee-safe scopes)
                  example:
                  - read:own_profile
                  - read:own_shifts
                  - write:own_attendance
                expires_at:
                  type: string
                  format: date-time
                  description: Optional expiration date
                  example: '2024-12-31T23:59:59Z'
      responses:
        '201':
          description: Token created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: API token created successfully
                  token:
                    "$ref": "#/components/schemas/DetailedPersonalApiToken"
                  warning:
                    type: string
                    example: Store this token securely. You will not be able to see
                      it again.
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Token limit exceeded or validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/tokens/scopes":
    get:
      tags:
      - API Token Management
      summary: Get available token scopes
      description: |
        Retrieve list of available scopes for personal API tokens
        with descriptions and categories.
      responses:
        '200':
          description: Scopes retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  available_scopes:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          example: read:own_profile
                        description:
                          type: string
                          example: View your profile information
                        category:
                          type: string
                          example: profile
                  scope_categories:
                    type: object
                    additionalProperties:
                      type: string
                    example:
                      profile: User profile and preferences
                      shifts: Shift and schedule information
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/tokens/{id}":
    get:
      tags:
      - API Token Management
      summary: Get token details
      description: Retrieve detailed information about a specific personal API token
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID
      responses:
        '200':
          description: Token details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    "$ref": "#/components/schemas/DetailedPersonalApiToken"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
    delete:
      tags:
      - API Token Management
      summary: Delete personal API token
      description: 'Delete (revoke) a personal API token. Requires current password
        for security.

        '
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID to delete
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Token deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: API token deleted successfully
                  deleted_token:
                    "$ref": "#/components/schemas/PersonalApiToken"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/tokens/{id}/rotate":
    patch:
      tags:
      - API Token Management
      summary: Rotate API token
      description: |
        Generate a new token value for an existing API token while preserving
        all other settings. Requires current password for security.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID to rotate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Token rotated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: API token rotated successfully
                  token:
                    "$ref": "#/components/schemas/DetailedPersonalApiToken"
                  old_token:
                    type: object
                    description: Information about the old token (without token value)
                  warning:
                    type: string
                    example: Update your applications with the new token. The old
                      token is no longer valid.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/tokens/{id}/usage":
    get:
      tags:
      - API Token Management
      summary: Get token usage statistics
      description: |
        Retrieve usage statistics and analytics for a personal API token.
        Note: Detailed usage tracking is being implemented.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID
      responses:
        '200':
          description: Usage statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  usage:
                    "$ref": "#/components/schemas/TokenUsage"
                  note:
                    type: string
                    example: Usage tracking is being implemented and will provide
                      detailed analytics soon.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/impersonate":
    post:
      tags:
      - Impersonation
      summary: Create impersonation token
      description: |
        Create a short-lived impersonation token to act on behalf of a specific user.
        Requires a service account with `impersonate` or `admin` scope.

        Impersonation tokens allow service accounts to access user-specific endpoints
        (e.g., `/api/v1/users/me`, `/api/v1/leave_balances`) as if authenticated as that user.

        All access using impersonation tokens is automatically logged for audit purposes.
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                  description: ID of the user to impersonate
                  example: 123
      responses:
        '201':
          description: Impersonation token created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      token:
                        type: string
                        description: Impersonation token (use in Authorization header)
                        example: a1b2c3d4e5f6...
                      expires_at:
                        type: string
                        format: date-time
                        description: Token expiration time
                        example: '2024-01-15T14:30:00Z'
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Service account does not have impersonate scope
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: forbidden
                  message: Service account with 'impersonate' or 'admin' scope required
        '404':
          description: User not found in current business
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: user_not_found
                  message: User not found in current business
    get:
      tags:
      - Impersonation
      summary: List or get impersonation token details
      description: |
        If called with a service account token, returns a list of active impersonation tokens
        created by that service account.

        If called with an impersonation token, returns details about the current impersonation
        session (impersonated user, impersonator, expiration, etc.).
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      responses:
        '200':
          description: Impersonation tokens retrieved successfully
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  description: List of tokens (when called with service account)
                  properties:
                    success:
                      type: boolean
                      example: true
                    data:
                      type: array
                      items:
                        type: object
                        properties:
                          token:
                            type: string
                            description: Impersonation token
                          impersonated_user_id:
                            type: integer
                            description: ID of the user being impersonated
                          expires_at:
                            type: string
                            format: date-time
                            description: Token expiration time
                - type: object
                  description: Current token details (when called with impersonation
                    token)
                  properties:
                    success:
                      type: boolean
                      example: true
                    data:
                      type: object
                      properties:
                        token:
                          type: string
                          description: Impersonation token
                        impersonated_user_id:
                          type: integer
                          description: ID of the user being impersonated
                        impersonator_user_id:
                          type: integer
                          description: ID of the service account user who created
                            this token
                        expires_at:
                          type: string
                          format: date-time
                          description: Token expiration time
                        active:
                          type: boolean
                          description: Whether the token is currently active (not
                            expired or revoked)
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Access denied
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/impersonate/{token}":
    delete:
      tags:
      - Impersonation
      summary: Revoke impersonation token
      description: |
        Revoke an impersonation token. Only the service account that created the token
        can revoke it.
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
        description: The impersonation token to revoke
      responses:
        '200':
          description: Token revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      message:
                        type: string
                        example: Impersonation token revoked
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Access denied
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Token not found or not owned by this service account
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/home":
    get:
      tags:
      - Home
      summary: Get home screen data
      description: "Returns comprehensive home screen data including dashboard widgets,
        \nuser profile, business context, notifications, and quick stats.\n\nThis
        endpoint provides all data needed to render a complete mobile \nhome screen
        in a single API call.\n\n### Widget System\nThe dashboard includes configurable
        widgets based on:\n- User role (employee, manager, admin, super_admin)\n-
        Enabled business features\n- Active marketplace apps\n\n### Performance Metrics\nIncludes
        user performance data such as:\n- Weekly hours worked\n- Attendance rate and
        punctuality\n- Completed shifts count\n\n### Real-time Data\n- Current attendance
        status\n- Upcoming shifts\n- Unread notifications\n- Quick action shortcuts\n"
      responses:
        '200':
          description: Home screen data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    allOf:
                    - "$ref": "#/components/schemas/User"
                    - type: object
                      properties:
                        onboarding_completed:
                          type: boolean
                          description: Whether user has completed onboarding
                        onboarding_progress:
                          type: number
                          minimum: 0
                          maximum: 100
                          description: Onboarding completion percentage
                        preferences:
                          type: object
                          description: User preferences and settings
                  business:
                    allOf:
                    - "$ref": "#/components/schemas/Business"
                    - type: object
                      properties:
                        settings:
                          type: object
                          properties:
                            shifts_scheduling_enabled:
                              type: boolean
                            time_attendance_enabled:
                              type: boolean
                            leave_management_enabled:
                              type: boolean
                            timesheets_enabled:
                              type: boolean
                            skills_certifications_enabled:
                              type: boolean
                            enabled_marketplace_apps:
                              type: array
                              items:
                                type: string
                              description: List of enabled marketplace app slugs
                            time_clock_kiosk_enabled:
                              type: boolean
                              description: 'Tenant runs a shared Time Clock Kiosk,
                                so punching moved off personal devices onto the tablet
                                at the site. Clients must stop offering Clock In /
                                Clock Out and show the kiosk card from current_status.kiosk
                                instead.

                                '
                            business_hours:
                              type: object
                              additionalProperties:
                                type: object
                                properties:
                                  open:
                                    type: string
                                    format: time
                                  close:
                                    type: string
                                    format: time
                                  closed:
                                    type: boolean
                            week_start_day:
                              type: string
                              enum:
                              - sunday
                              - monday
                              - tuesday
                              - wednesday
                              - thursday
                              - friday
                              - saturday
                              default: monday
                  dashboard:
                    type: object
                    properties:
                      widgets:
                        type: array
                        items:
                          type: object
                          properties:
                            name:
                              type: string
                              description: Widget identifier
                            title:
                              type: string
                              description: Display title
                            description:
                              type: string
                              description: Widget description
                            category:
                              type: string
                              enum:
                              - platform
                              - core_app
                              - marketplace_app
                            icon:
                              type: string
                              description: FontAwesome icon class
                            size:
                              type: string
                              description: Bootstrap grid size class
                            type:
                              type: string
                              description: Widget category type
                            priority:
                              type: integer
                              description: Display priority (lower = higher priority)
                            data:
                              type: object
                              description: |
                                Widget-specific data content following the Simple Widget Specification. Contains both structured data and markdown content for flexible mobile rendering.

                                **Core Structure**:
                                - Original structured data (actions, shifts, notifications, etc.)
                                - `markdown_summary`: Rich markdown content for mobile rendering
                                - `display_hint`: Layout hint (grid, list, feed, card)

                                **Platform Widgets**:
                                - `quick_actions`: { actions: [{ title, path, icon, variant }], markdown_summary, display_hint: 'grid' }
                                - `notifications`: { recent: [], unread_count: 0, markdown_summary, display_hint: 'feed' }
                                - `user_profile_summary`: { greeting, weekly_hours, current_status, markdown_summary, display_hint: 'card' }

                                **Core App Widgets**:
                                - `upcoming_shifts`: { shifts: [], user_role, show_team_view, markdown_summary, display_hint: 'list' }
                                - `quick_attendance`: { current_status: { status, shift: { id, date, start_time, end_time, location, duration, is_ad_hoc }, clocked_in_at, duration, can_clock_in }, today_hours, can_clock_in, markdown_summary, display_hint: 'card' }
                                - `leave_requests`: { leave_requests: [{ id, start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), formatted_dates, duration_days, status_badge: { class, text }, leave_type: { name, color }, reason, created_at, url }], leave_balance: [], quick_actions: [], user_role, show_manager_view, markdown_summary, display_hint: 'list' }

                                **Marketplace App Widgets**:
                                - `epms_*`: Performance management data with markdown_summary and display_hint
                                - `training_connect_*`: Training data with markdown_summary and display_hint
                                - `okr_progress`: OKR data with markdown_summary and display_hint

                                This hybrid approach provides both structured data for custom rendering and markdown content for quick implementation.
                              additionalProperties: true
                              properties:
                                markdown_summary:
                                  type: string
                                  description: Rich markdown content for mobile rendering.
                                    Contains formatted text with emojis, links, and
                                    styling cues.
                                  example: "## Quick Actions\n\n\U0001F535 **[Clock
                                    In](/attendance/clock_in)**\n\U0001F4C5 **[My
                                    Schedule](/shifts/my_shifts)**\n"
                                display_hint:
                                  type: string
                                  enum:
                                  - grid
                                  - list
                                  - feed
                                  - card
                                  description: |
                                    Layout hint for mobile apps:
                                    - grid: 2-3 column layout for actions/buttons
                                    - list: Single column with dividers for items
                                    - feed: Card-based with timestamps for activity
                                    - card: Full-width summary display
                              example:
                                actions:
                                - title: Clock In
                                  path: "/attendance/clock_in"
                                  icon: fas fa-play
                                  variant: primary
                                - title: My Schedule
                                  path: "/shifts/my_shifts"
                                  icon: fas fa-calendar
                                  variant: outline-info
                            last_updated:
                              type: string
                              format: date-time
                            refresh_interval:
                              type: integer
                              description: Recommended refresh interval in seconds
                            data_schema:
                              type: object
                              description: |
                                JSON Schema describing the expected structure of the 'data' field.
                                Helps mobile developers understand widget data format and handle new widgets gracefully.
                              additionalProperties: true
                            version:
                              type: string
                              description: |
                                Widget version for mobile app compatibility.
                                Increment when widget data structure changes significantly.
                              example: '1.2'
                      widget_count:
                        type: integer
                        description: Total number of active widgets
                      performance_metrics:
                        type: object
                        properties:
                          current_week:
                            type: object
                            properties:
                              hours_worked:
                                type: number
                                format: float
                              shifts_completed:
                                type: integer
                              on_time_rate:
                                type: number
                                format: float
                                minimum: 0
                                maximum: 100
                              attendance_rate:
                                type: number
                                format: float
                                minimum: 0
                                maximum: 100
                          current_month:
                            type: object
                            properties:
                              total_shifts:
                                type: integer
                              hours_worked:
                                type: number
                                format: float
                              performance_score:
                                type: number
                                format: float
                                minimum: 0
                                maximum: 100
                      role_context:
                        type: object
                        properties:
                          current_role:
                            type: string
                            enum:
                            - employee
                            - manager
                            - admin
                            - super_admin
                          permissions:
                            type: array
                            items:
                              type: string
                            description: User's permissions in current business
                          available_actions:
                            type: array
                            items:
                              type: object
                              properties:
                                name:
                                  type: string
                                label:
                                  type: string
                                icon:
                                  type: string
                  attendance_policy:
                    type: object
                    nullable: true
                    description: 'The tenant''s clock-in policy, resolved for the
                      calling user, so a client can render the clock-in gate and know
                      which verifications to gather without re-implementing the rules.
                      `null` when Time & Attendance is disabled for the business or
                      the business has no security setting yet. `settings` mirrors
                      every punch-behaviour field on /admin/attendance_security/settings,
                      using the same names. Kiosk PIN derivation (`default_pin_rule`,
                      `default_pin_custom_value`) is deliberately never returned;
                      the caller''s own PIN-free kiosk credentials ship in `current_status.kiosk`
                      instead.

                      '
                    properties:
                      clock_in_gate:
                        type: object
                        properties:
                          active:
                            type: boolean
                            description: 'The decision, and the only field a client
                              should branch on. Already folds in scope, admin exemption,
                              approved leave and "already clocked in". Comes from
                              the same resolver the web gate uses, so the two cannot
                              drift.

                              '
                          mode:
                            type: string
                            nullable: true
                            enum:
                            - block
                            - banner
                            description: 'How to present the gate — `block` redirects
                              until clocked in, `banner` reminds without blocking.
                              `null` when the gate is disabled for the tenant.

                              '
                          enabled:
                            type: boolean
                            description: 'Raw tenant setting (is the gate switched
                              on at all). Informational — re-deriving the gate from
                              this will drift from `active`.

                              '
                          scope:
                            type: string
                            enum:
                            - punch_eligible
                            - all_users
                            description: Raw tenant setting for who the gate applies
                              to. Informational.
                      settings:
                        type: object
                        description: Straight projection of the tenant's attendance
                          security settings.
                        properties:
                          allow_remote_clock_in:
                            type: boolean
                            description: Offer a "Working remotely" option in the
                              location picker.
                          allow_offsite_clock_in:
                            type: boolean
                            description: Offer an "Off-site / traveling" option with
                              an optional note.
                          require_shift_for_clock_in:
                            type: boolean
                            description: 'A punch is only accepted against a scheduled
                              shift. When false, an ad-hoc shift is created for an
                              unscheduled clock-in.

                              '
                          require_location_verification:
                            type: boolean
                            description: 'Master switch for geographic verification.
                              Each location must also enable geofencing for enforcement
                              to take effect.

                              '
                          require_device_verification:
                            type: boolean
                          require_photo_verification:
                            type: boolean
                            description: Employee must capture a photo when clocking
                              in or out.
                          enforce_ip_validation_for_clock_actions:
                            type: boolean
                            description: 'Master switch for the IP check. When false,
                              no IP validation happens at all regardless of `ip_validation_mode`.

                              '
                          ip_validation_mode:
                            type: string
                            enum:
                            - passive
                            - warning
                            - strict
                            description: 'Only meaningful when `enforce_ip_validation_for_clock_actions`
                              is true. `passive` records only, `warning` flags for
                              review, `strict` refuses the punch from an unapproved
                              IP.

                              '
                          enforce_time_window_restrictions:
                            type: boolean
                            description: 'Apply the three buffers below. Note the
                              window check is skipped entirely for ad-hoc shifts even
                              when this is true, so do not present a window on a punch
                              against one.

                              '
                          early_clock_in_buffer_minutes:
                            type: integer
                            description: 'Minutes before scheduled shift start that
                              clock-in is allowed. `0` requires the exact start time.

                              '
                          late_clock_in_buffer_minutes:
                            type: integer
                            description: 'Minutes after shift start before a clock-in
                              is flagged as outside the window. `0` requires the exact
                              start time.

                              '
                          late_clock_out_buffer_minutes:
                            type: integer
                            description: 'Minutes after shift end before a clock-out
                              is flagged. `0` disables the check — late clock-outs
                              are never flagged — rather than meaning zero tolerance,
                              which is the opposite of how `0` reads on the two clock-in
                              buffers above. Early clock-out is never restricted at
                              all.

                              '
                          enable_auto_clock_out:
                            type: boolean
                            description: 'The server closes forgotten punches. Surfaced
                              so a client can warn the employee rather than have an
                              auto clock-out look like a lost shift.

                              '
                          auto_clock_out_max_hours:
                            type: integer
                            description: Hours after clock-in at which the punch is
                              closed automatically.
                          auto_clock_out_after_shift_end_minutes:
                            type: integer
                            description: For scheduled shifts, minutes after shift
                              end before auto clock-out.
                          enable_clock_out_reminders:
                            type: boolean
                            description: 'Send a reminder before the shift ends. Independent
                              of `enable_auto_clock_out` — the sweep selects the union
                              of the two flags, so a tenant may receive reminders
                              without the server ever auto-closing a punch. Do not
                              present this as a sub-setting of auto clock-out.

                              '
                          effective_clock_out_reminder_minutes:
                            type: integer
                            minimum: 5
                            description: 'Minutes before scheduled shift end that
                              the reminder is sent. This is the stored `clock_out_reminder_minutes_before`
                              clamped to the reminder sweep''s cron floor, not the
                              raw column — a row holding a lower value (or none) still
                              reminds at the floor, so this is the only number safe
                              to show an employee.

                              '
                  broadcast_alerts:
                    type: object
                    nullable: true
                    description: 'Unread/response badge counts for the Broadcast &
                      Alerts app. `null` when the app is not enabled for the business.
                      Each `count` matches the `all` value the corresponding list
                      API returns in `meta.segment_counts`, so the home badge can''t
                      drift from the list. A `count` may be `null` if its computation
                      fails (the node is still returned).

                      '
                    properties:
                      broadcast:
                        type: object
                        properties:
                          count:
                            type: integer
                            nullable: true
                            description: Received broadcasts the user has not read
                      alert:
                        type: object
                        properties:
                          count:
                            type: integer
                            nullable: true
                            description: Alert responses (acknowledge / safety check-in)
                              the user owes
                          items:
                            type: array
                            maxItems: 10
                            description: 'The response-pending alerts the count covers
                              — ack-required alerts the user has not acknowledged,
                              or safety-check-in alerts the user has not responded
                              to — newest published first, capped at 10 (a preview;
                              the full total is in `count`).

                              '
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                title:
                                  type: string
                  forms:
                    type: object
                    nullable: true
                    description: 'Forms pending-approval badge. `null` unless the
                      Forms app is enabled for the business AND the caller may approve/reject
                      submissions (admin / manager / Forms App Admin) — the same gate
                      as GET /api/v1/forms/approvals. Returned `null` (never omitted
                      erroneously) if the count computation fails, so the home payload
                      is never dropped.

                      '
                    properties:
                      pending_approval_count:
                        type: integer
                        description: 'Submissions awaiting the caller''s review. Role-aware,
                          matching the size of GET /api/v1/forms/approvals so the
                          badge can''t drift from that list: admins and Forms App
                          Admins count the business-wide pending_review set (submitted
                          + under_review); a manager counts only the under_review
                          submissions from their own direct reports.

                          '
                  notifications:
                    type: object
                    properties:
                      unread_count:
                        type: integer
                        description: Number of unread notifications
                      total_count:
                        type: integer
                        description: Total notifications for user
                      recent:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            title:
                              type: string
                            message:
                              type: string
                            type:
                              type: string
                            priority:
                              type: string
                              enum:
                              - low
                              - normal
                              - high
                              - urgent
                              default: normal
                            created_at:
                              type: string
                              format: date-time
                            read_at:
                              type: string
                              format: date-time
                              nullable: true
                            action_url:
                              type: string
                              nullable: true
                  quick_stats:
                    type: object
                    properties:
                      today_shifts:
                        type: integer
                        description: Number of shifts today
                      week_hours:
                        type: number
                        format: float
                        description: Hours worked this week
                      unread_notifications:
                        type: integer
                        description: Unread notification count
                      pending_approvals:
                        type: integer
                        description: Items pending approval (managers/admins only)
                      team_size:
                        type: integer
                        description: Team size (managers/admins only)
                      today_employee_count:
                        type: integer
                        description: Employees scheduled today (managers/admins only)
                  _meta:
                    allOf:
                    - "$ref": "#/components/schemas/PiggybackMeta"
                    - type: object
                      properties:
                        refresh_interval:
                          type: integer
                          description: Recommended refresh interval in seconds
                          example: 300
                        server_time:
                          type: string
                          format: date-time
                          description: Current server time
                        mobile_optimized:
                          type: boolean
                          example: true
                        cache_key:
                          type: string
                          description: Cache key for client-side caching
              examples:
                employee_home:
                  summary: Employee home screen
                  value:
                    user:
                      id: 123
                      email: john.doe@company.com
                      first_name: John
                      last_name: Doe
                      full_name: John Doe
                      role: employee
                      active: true
                      avatar_url:
                      phone: "+1234567890"
                      onboarding_completed: true
                      onboarding_progress: 100
                      preferences: {}
                    business:
                      id: 456
                      name: Example Company
                      subdomain: example
                      timezone: America/New_York
                      logo_url:
                      branding:
                        primary_button_color: "#2e63b3"
                        secondary_button_color: "#6c757d"
                        status_success_color: "#198754"
                        status_error_color: "#dc3545"
                        status_warning_color: "#ffc107"
                        status_info_color: "#0dcaf0"
                        mobile_header_background: "#2e63b3"
                        mobile_header_text: "#FFFFFF"
                        mobile_footer_background: "#FFFFFF"
                        mobile_footer_icon_active: "#2e63b3"
                        mobile_footer_icon_inactive: "#6c757d"
                      settings:
                        shifts_scheduling_enabled: true
                        time_attendance_enabled: true
                        leave_management_enabled: true
                        enabled_marketplace_apps:
                        - epms
                        - training_connect
                        week_start_day: monday
                    dashboard:
                      widgets:
                      - name: quick_actions
                        title: Quick Actions
                        category: platform
                        icon: fas fa-bolt
                        size: col-12
                        priority: 1
                        data:
                          actions:
                          - title: Clock In
                            path: "/attendance/clock_in"
                            icon: fas fa-play
                            variant: primary
                          - title: My Schedule
                            path: "/shifts/my_shifts"
                            icon: fas fa-calendar
                            variant: outline-info
                        data_schema:
                          type: object
                          properties:
                            actions:
                              type: array
                              items:
                                type: object
                                properties:
                                  title:
                                    type: string
                                  path:
                                    type: string
                                  icon:
                                    type: string
                                  variant:
                                    type: string
                        version: '1.2'
                        refresh_interval: 300
                      - name: upcoming_shifts
                        title: Upcoming Shifts
                        category: core_app
                        icon: fas fa-calendar
                        size: col-md-6
                        priority: 3
                        data:
                          shifts:
                          - id: 789
                            title: Morning Shift
                            start_time: '2024-01-15T08:00:00Z'
                            end_time: '2024-01-15T16:00:00Z'
                            location: Main Office
                          total_count: 5
                        refresh_interval: 300
                      - name: quick_attendance
                        title: Time Clock
                        category: core_app
                        icon: fas fa-clock
                        size: col-md-6
                        priority: 2
                        data:
                          current_status:
                            status: scheduled
                            shift:
                              id: 789
                              date: '2024-01-15'
                              start_time: '08:00 AM'
                              end_time: 04:00 PM
                              location: Main Office
                              duration: 8h 0m
                              is_ad_hoc: false
                            can_clock_in: true
                          today_hours: 0.0
                          markdown_summary: 'Next Shift: Today, 08:00 AM - 04:00 PM
                            at Main Office'
                          display_hint: card
                        refresh_interval: 60
                      widget_count: 3
                      performance_metrics:
                        current_week:
                          hours_worked: 32.5
                          shifts_completed: 4
                          on_time_rate: 95.2
                          attendance_rate: 98.1
                        current_month:
                          total_shifts: 18
                          hours_worked: 142.5
                          performance_score: 96.5
                      role_context:
                        current_role: employee
                        permissions:
                        - read_dashboard
                        - view_profile
                        available_actions:
                        - name: view_profile
                          label: View Profile
                          icon: fas fa-user
                        - name: clock_in_out
                          label: Clock In/Out
                          icon: fas fa-clock
                    notifications:
                      unread_count: 3
                      total_count: 25
                      recent:
                      - id: 101
                        title: Schedule Update
                        message: Your shift on Jan 15 has been updated
                        type: shift_update
                        priority: normal
                        created_at: '2024-01-14T10:30:00Z'
                        read_at:
                        action_url: "/shifts/789"
                    quick_stats:
                      today_shifts: 1
                      week_hours: 32.5
                      unread_notifications: 3
                    _meta:
                      api_version: v1
                      generated_at: '2024-01-15T12:00:00Z'
                      refresh_interval: 300
                      server_time: '2024-01-15T12:00:00Z'
                      mobile_optimized: true
                      cache_key: home_123_456_1705320000
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: home_data_error
                      message:
                        type: string
                        example: Failed to load home screen data
  "/users/me":
    get:
      tags:
      - Users
      summary: Get current user profile (Enhanced)
      description: |
        Returns the authenticated user's profile and business information with optional
        piggyback data for enhanced mobile and web app experiences.

        ## Enhanced Features
        Use the `include` query parameter to request additional data:

        - `complete_profile` - Full profile with professional and compensation data
        - `dashboard_summary` - Dashboard stats and quick actions
        - `intelligence_insights` - AI-powered insights and recommendations
        - `preferences_detailed` - Detailed preferences with metadata

        ## Examples
        ```
        GET /api/v1/users/me?include=complete_profile,dashboard_summary
        GET /api/v1/users/me?include=intelligence_insights
        ```
      parameters:
      - name: include
        in: query
        description: |
          Comma-separated list of additional data to include in the response.
          Available options: complete_profile, dashboard_summary, intelligence_insights, preferences_detailed
        required: false
        schema:
          type: string
          example: complete_profile,dashboard_summary
      responses:
        '200':
          description: User profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    allOf:
                    - "$ref": "#/components/schemas/User"
                    - type: object
                      properties:
                        permissions:
                          type: array
                          items:
                            type: string
                          description: User's permissions
                        feature_flags:
                          type: object
                          description: Feature flags for the user
                  business:
                    "$ref": "#/components/schemas/Business"
                  _meta:
                    "$ref": "#/components/schemas/PiggybackMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/users/me/preferences":
    patch:
      tags:
      - Users
      summary: Update user preferences (Enhanced)
      description: |
        Update the authenticated user's preferences with support for category-based updates
        and smart defaults integration.

        ## Enhanced Features
        - Category-specific updates using `?category=notifications`
        - Smart defaults with `?include_smart_defaults=true`
        - Atomic updates to prevent conflicts

        ## Examples
        ```
        PATCH /api/v1/users/me/preferences?category=notifications
        PATCH /api/v1/users/me/preferences?include_smart_defaults=true
        ```
      parameters:
      - name: category
        in: query
        description: |
          Update only a specific preference category.
          Available categories: notifications, availability, communication, privacy
        required: false
        schema:
          type: string
          enum:
          - notifications
          - availability
          - communication
          - privacy
          example: notifications
      - name: include_smart_defaults
        in: query
        description: Include AI-powered smart defaults in the response
        required: false
        schema:
          type: boolean
          example: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - preferences
              properties:
                preferences:
                  type: object
                  description: User preferences to update
                  example:
                    theme: dark
                    notifications_enabled: true
                    language: en
      responses:
        '200':
          description: Preferences updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    properties:
                      id:
                        type: integer
                      preferences:
                        type: object
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/users/{id}/profile":
    get:
      tags:
      - Users
      security:
      - BearerAuth: []
      summary: Mobile profile card for a user (numeric id lookup)
      description: |
        Returns a mobile-friendly profile card for the user with the given
        numeric `User#id` — the value mobile clients receive from feed
        payloads, comment author blocks, and mention search results.

        Distinct from `GET /api/v1/users/{id}` which is the HRIS endpoint
        keyed on `mango_employee_id`. Tenant-scoped: only users with an
        active `UserBusiness` row in the caller's business are resolvable;
        cross-tenant or inactive ids return 404.

        Direct reports are filtered to the caller's business + active
        memberships, so the list never leaks ex-employees or users from
        other tenants the manager may belong to.

        `profile_picture.*` URLs and `mobile_url` are absolute so JSON
        clients can render / open them directly without resolving against
        a base URL.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
        description: Numeric User#id.
      responses:
        '200':
          description: Mobile profile card
          content:
            application/json:
              schema:
                type: object
                required:
                - user
                properties:
                  user:
                    type: object
                    required:
                    - id
                    - name
                    - email
                    - profile_picture
                    - direct_reports
                    - mobile_url
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                        nullable: true
                      email:
                        type: string
                        format: email
                        nullable: true
                      contact:
                        type: string
                        nullable: true
                        description: Primary phone number (User#phone).
                      employee_id:
                        type: string
                        nullable: true
                        description: HRIS-facing mango_employee_id (e.g. E1234567).
                      job_title:
                        type: string
                        nullable: true
                      employment_type:
                        type: string
                        nullable: true
                        description: 'One of EMPLOYEE_TYPES: Full-Time, Part-Time,
                          Seasonal/Temp, Hourly, Salaried, Contractor, Other.'
                      hire_date:
                        type: string
                        format: date
                        nullable: true
                      profile_picture:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: 40x40 variant; absolute URL.
                          full_url:
                            type: string
                            format: uri
                            nullable: true
                            description: 200x200 variant; absolute URL.
                      primary_location:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          address:
                            type: string
                            nullable: true
                      direct_reports:
                        type: array
                        description: Direct reports filtered to the caller's business
                          + active UserBusiness.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              nullable: true
                            email:
                              type: string
                              format: email
                              nullable: true
                            profile_picture:
                              type: object
                              properties:
                                thumbnail_url:
                                  type: string
                                  format: uri
                                  nullable: true
                                full_url:
                                  type: string
                                  format: uri
                                  nullable: true
                      mobile_url:
                        type: string
                        format: uri
                        description: |
                          Absolute deep link to the user's mobile profile,
                          following the `/m/...` convention used by
                          `mobile_app_url(slug)`. Clients can hand this to
                          the WebView or in-app router.
        '400':
          description: id is not numeric
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          description: User not found or not in the caller business
  "/profile":
    get:
      tags:
      - User Profile Management
      summary: Get complete user profile (redirects to /users/me)
      description: "Redirects to the enhanced /users/me endpoint. This endpoint is
        provided \nfor consistency but clients should use /users/me directly with
        include parameters.\n"
      responses:
        '302':
          description: Redirect to /users/me
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/profile/actions":
    post:
      tags:
      - User Profile Management
      summary: Execute profile actions
      description: "Execute various profile-related actions in a single endpoint.
        This allows\nfor specific operations like avatar updates, skill verification
        requests,\ncareer goal setting, and more.\n\n**Note:** Use `action_type` parameter
        (preferred) or `action` parameter for the action. \nUsing `action_type` is
        recommended as `action` is a reserved Rails parameter.\n"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action_type
              properties:
                action_type:
                  type: string
                  enum:
                  - update_avatar
                  - request_skill_verification
                  - set_career_goal
                  - update_emergency_contact
                  - request_manager_change
                  - update_communication_preferences
                  description: The action to perform (preferred parameter)
                  example: update_avatar
                action:
                  type: string
                  enum:
                  - update_avatar
                  - request_skill_verification
                  - set_career_goal
                  - update_emergency_contact
                  - request_manager_change
                  - update_communication_preferences
                  description: The action to perform (legacy, use action_type instead)
                  deprecated: true
                  example: update_avatar
                data:
                  type: object
                  description: Action-specific data
                  example:
                    avatar: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...
            examples:
              update_avatar:
                summary: Update profile avatar
                value:
                  action_type: update_avatar
                  data:
                    avatar: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...
              request_skill_verification:
                summary: Request skill verification
                value:
                  action_type: request_skill_verification
                  data:
                    skill_id: 123
              set_career_goal:
                summary: Set career goal
                value:
                  action_type: set_career_goal
                  data:
                    goal: Complete React Certification
                    target_date: '2024-06-01'
              update_emergency_contact:
                summary: Update emergency contact
                value:
                  action_type: update_emergency_contact
                  data:
                    contact_info:
                      name: Jane Doe
                      phone: "+1234567890"
                      relationship: Spouse
      responses:
        '200':
          description: Action executed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Avatar updated successfully
                  data:
                    type: object
                    description: Action-specific response data
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/profile/bulk":
    patch:
      tags:
      - User Profile Management
      summary: Bulk update profile sections
      description: |
        Update multiple profile sections atomically in a single transaction.
        This ensures data consistency when updating related profile information.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - updates
              properties:
                updates:
                  type: object
                  description: Profile sections to update
                  properties:
                    personal:
                      type: object
                      description: Personal information updates
                      properties:
                        first_name:
                          type: string
                          example: John
                        last_name:
                          type: string
                          example: Doe
                        phone:
                          type: string
                          example: "+1234567890"
                        time_zone:
                          type: string
                          example: America/New_York
                        employee_type:
                          type: string
                          example: Full-Time
                        job_title:
                          type: string
                          example: Senior Developer
                        department:
                          type: string
                          example: Engineering
                    preferences:
                      type: object
                      description: Preferences updates by category
                      properties:
                        notifications:
                          type: object
                          example:
                            shift_reminders: true
                            schedule_changes: true
                        availability:
                          type: object
                          example:
                            willing_for_extra_shifts: true
                            max_shifts_per_week: 5
                        communication:
                          type: object
                          example:
                            preferred_method: email
                            frequency: immediate
                    professional:
                      type: object
                      description: Professional information updates
                      properties:
                        skills_for_roles:
                          type: object
                          example:
                            developer:
                            - JavaScript
                            - React
                            - Node.js
            example:
              updates:
                personal:
                  phone: "+1234567890"
                  time_zone: America/New_York
                preferences:
                  notifications:
                    shift_reminders: true
                    schedule_changes: true
                  availability:
                    willing_for_extra_shifts: true
                    max_shifts_per_week: 5
      responses:
        '200':
          description: Bulk update completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Bulk update completed successfully
                  data:
                    type: object
                    properties:
                      updated_sections:
                        type: array
                        items:
                          type: string
                        example:
                        - personal
                        - preferences
                      results:
                        type: object
                        description: Results for each updated section
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/users/me/security":
    get:
      tags:
      - Account Settings
      summary: Get user security settings
      description: |
        Retrieve comprehensive security settings for the authenticated user including
        two-factor authentication status, trusted devices, security questions, and login history.
      responses:
        '200':
          description: Security settings retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  security:
                    "$ref": "#/components/schemas/UserSecuritySettings"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Account Settings
      summary: Update user security settings
      description: |
        Update security settings including two-factor authentication, trusted devices,
        and security questions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                security:
                  type: object
                  properties:
                    two_factor_auth:
                      type: object
                      properties:
                        enabled:
                          type: boolean
                          description: Enable or disable two-factor authentication
                    trusted_devices:
                      type: object
                      properties:
                        remove_device_id:
                          type: string
                          description: ID of device to remove from trusted list
                    security_questions:
                      type: object
                      properties:
                        question_1:
                          type: string
                          description: First security question
                        answer_1:
                          type: string
                          description: Answer to first security question
                        question_2:
                          type: string
                          description: Second security question (optional)
                        answer_2:
                          type: string
                          description: Answer to second security question (optional)
      responses:
        '200':
          description: Security settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  security:
                    "$ref": "#/components/schemas/UserSecuritySettings"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/account_settings/dashboard":
    get:
      tags:
      - Account Settings
      summary: Get account settings dashboard
      description: |
        Retrieve a comprehensive dashboard view of account settings including
        profile completion status, security overview, notification summary, and quick actions.
      responses:
        '200':
          description: Dashboard data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    "$ref": "#/components/schemas/AccountSettingsDashboard"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/account_settings/privacy":
    get:
      tags:
      - Account Settings
      summary: Get privacy settings
      description: |
        Retrieve privacy and data management settings including data sharing preferences,
        visibility settings, and data retention options.
      responses:
        '200':
          description: Privacy settings retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  privacy:
                    "$ref": "#/components/schemas/UserPrivacySettings"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Account Settings
      summary: Update privacy settings
      description: |
        Update privacy and data management settings including data sharing,
        visibility preferences, and data retention options.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                privacy:
                  "$ref": "#/components/schemas/UserPrivacySettingsUpdate"
      responses:
        '200':
          description: Privacy settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  privacy:
                    "$ref": "#/components/schemas/UserPrivacySettings"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/account_settings/communication":
    get:
      tags:
      - Account Settings
      summary: Get communication preferences
      description: |
        Retrieve communication preferences including language, timezone,
        communication style, AI assistant settings, and meeting preferences.
      responses:
        '200':
          description: Communication settings retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  communication:
                    "$ref": "#/components/schemas/UserCommunicationSettings"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Account Settings
      summary: Update communication preferences
      description: |
        Update communication preferences including language, timezone,
        communication style, AI assistant settings, and meeting preferences.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                communication:
                  "$ref": "#/components/schemas/UserCommunicationSettingsUpdate"
      responses:
        '200':
          description: Communication settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  communication:
                    "$ref": "#/components/schemas/UserCommunicationSettings"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/onboarding":
    get:
      tags:
      - Onboarding
      summary: Get onboarding progress
      description: |
        Returns the current user's onboarding status, progress percentage, and next steps.
        This endpoint provides a complete overview of the onboarding journey for mobile apps.
      responses:
        '200':
          description: Onboarding progress retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  completed:
                    type: boolean
                    description: Whether onboarding is fully completed
                    example: false
                  current_step:
                    type: string
                    description: Current onboarding step name
                    example: availability_shift_preferences
                  progress_percentage:
                    type: integer
                    description: Completion percentage (0-100)
                    example: 60
                  steps_completed:
                    type: integer
                    description: Number of steps completed
                    example: 3
                  steps_total:
                    type: integer
                    description: Total number of onboarding steps
                    example: 6
                  estimated_completion_time:
                    type: string
                    description: Estimated time to complete remaining steps
                    example: 10-15 minutes
                  next_action:
                    type: string
                    nullable: true
                    description: URL for the next step to complete
                    example: "/api/v1/onboarding/steps/availability_shift_preferences"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/onboarding/steps":
    get:
      tags:
      - Onboarding
      summary: List all onboarding steps
      description: "Returns a complete list of onboarding steps with their status,
        accessibility, \nand metadata. Useful for displaying progress indicators and
        step navigation.\n"
      responses:
        '200':
          description: Onboarding steps retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  steps:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          description: Step identifier
                          example: profile_completion
                        display_name:
                          type: string
                          description: Human-readable step name
                          example: Complete Your Profile
                        position:
                          type: integer
                          description: Step position in sequence
                          example: 1
                        status:
                          type: string
                          enum:
                          - pending
                          - completed
                          - skipped
                          description: Current step status
                          example: completed
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When step was completed
                        accessible:
                          type: boolean
                          description: Whether step can be accessed now
                          example: true
                        required:
                          type: boolean
                          description: Whether step is required
                          example: true
                        estimated_time:
                          type: string
                          description: Estimated completion time
                          example: 3-5 minutes
                        description:
                          type: string
                          description: Step description
                          example: Complete your basic profile information
                        url:
                          type: string
                          description: API URL for this step
                          example: "/api/v1/onboarding/steps/profile_completion"
                  total_count:
                    type: integer
                    description: Total number of steps
                    example: 6
                  completed_count:
                    type: integer
                    description: Number of completed steps
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/onboarding/steps/{step_name}":
    get:
      tags:
      - Onboarding
      summary: Get step details
      description: |
        Returns detailed information about a specific onboarding step including
        form fields, current data, validation rules, and help text.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      responses:
        '200':
          description: Step details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    example: profile_completion
                  display_name:
                    type: string
                    example: Complete Your Profile
                  status:
                    type: string
                    enum:
                    - pending
                    - completed
                    - skipped
                    example: pending
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  accessible:
                    type: boolean
                    example: true
                  required:
                    type: boolean
                    example: true
                  estimated_time:
                    type: string
                    example: 3-5 minutes
                  description:
                    type: string
                    example: Complete your basic profile information
                  form_fields:
                    type: array
                    description: Form fields for this step
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          example: first_name
                        type:
                          type: string
                          example: text
                        required:
                          type: boolean
                          example: true
                        label:
                          type: string
                          example: First Name
                  current_data:
                    type: object
                    description: Current values for form fields
                    example:
                      first_name: John
                      last_name: Doe
                      email: john.doe@example.com
                  validation_rules:
                    type: object
                    description: Validation rules for form fields
                    example:
                      first_name:
                        required: true
                        min_length: 1
                  help_text:
                    type: string
                    nullable: true
                    description: Help text for this step
                    example: This information helps us personalize your experience
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Step not accessible
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Onboarding
      summary: Complete onboarding step
      description: |
        Updates and completes a specific onboarding step with the provided data.
        This will validate the input, save the data, and update the user's progress.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Step-specific data (varies by step)
              example:
                user:
                  first_name: John
                  last_name: Doe
                  email: john.doe@example.com
                  phone: "+1234567890"
                  time_zone: America/New_York
      responses:
        '200':
          description: Step completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  step_completed:
                    type: boolean
                    example: true
                  current_step:
                    type: string
                    nullable: true
                    description: Next step to complete
                    example: role_location_assignment
                  progress_percentage:
                    type: integer
                    example: 33
                  onboarding_completed:
                    type: boolean
                    example: false
                  next_step:
                    type: string
                    nullable: true
                    example: role_location_assignment
                  next_action:
                    type: string
                    nullable: true
                    example: "/api/v1/onboarding/steps/role_location_assignment"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Step not accessible
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/onboarding/steps/{step_name}/enhanced":
    get:
      tags:
      - Onboarding
      summary: Get enhanced step information with AI assistance
      description: |
        Returns comprehensive step information including smart defaults, AI suggestions,
        and intelligent assistance. This endpoint consolidates multiple features:
        - Smart form pre-filling based on user context
        - AI-powered preference suggestions
        - Similar employee patterns and benchmarks
        - Contextual tips and completion guidance
        - Personalized time estimates
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      responses:
        '200':
          description: Enhanced step information retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    example: profile_completion
                  display_name:
                    type: string
                    example: Complete Your Profile
                  status:
                    type: string
                    enum:
                    - pending
                    - completed
                    - skipped
                    example: pending
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  accessible:
                    type: boolean
                    example: true
                  required:
                    type: boolean
                    example: true
                  estimated_time:
                    type: string
                    example: 3-5 minutes
                  description:
                    type: string
                    example: Complete your basic profile information
                  form_fields:
                    type: array
                    description: Enhanced form fields with smart suggestions
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          example: time_zone
                        type:
                          type: string
                          example: select
                        required:
                          type: boolean
                          example: true
                        label:
                          type: string
                          example: Time Zone
                        suggested_value:
                          type: string
                          nullable: true
                          example: Pacific Time (US & Canada)
                        confidence:
                          type: string
                          enum:
                          - high
                          - medium
                          - low
                          example: high
                        smart_help:
                          type: string
                          nullable: true
                          example: We detected your timezone automatically
                  current_data:
                    type: object
                    description: Current values with smart defaults applied
                    example:
                      first_name: John
                      last_name: Doe
                      time_zone: Pacific Time (US & Canada)
                  ai_suggestions:
                    type: object
                    description: AI-powered suggestions and insights
                    properties:
                      timezone_confidence:
                        type: string
                        example: high
                      profile_completeness_score:
                        type: integer
                        example: 75
                      suggested_improvements:
                        type: array
                        items:
                          type: string
                        example:
                        - Add a profile photo to help colleagues recognize you
                  smart_defaults:
                    type: object
                    description: Intelligent defaults based on context
                    properties:
                      timezone_detected:
                        type: string
                        nullable: true
                        example: Pacific Time (US & Canada)
                      role_based_suggestions:
                        type: object
                        example:
                          role_name: Software Engineer
                          typical_hours:
                            min: 35
                            max: 40
                  similar_employee_patterns:
                    type: object
                    description: Patterns from similar employees
                    properties:
                      common_availability_patterns:
                        type: object
                        example:
                          avg_min_hours: 20
                          avg_max_hours: 40
                      typical_completion_time:
                        type: string
                        example: 2 days
                      success_factors:
                        type: array
                        items:
                          type: string
                        example:
                        - Complete profiles (85% average)
                        - Email notifications enabled (78%)
                  completion_tips:
                    type: array
                    description: Contextual tips for completing this step
                    items:
                      type: string
                    example:
                    - Adding a profile photo helps colleagues recognize you
                    - Setting your correct timezone ensures accurate scheduling
                  common_mistakes:
                    type: array
                    description: Common mistakes to avoid
                    items:
                      type: string
                    example:
                    - Forgetting to set timezone (causes scheduling issues)
                    - Using incomplete contact information
                  estimated_completion_time:
                    type: string
                    description: Personalized time estimate
                    example: 3-5 minutes
                  progress_context:
                    type: object
                    description: Progress context and benchmarks
                    properties:
                      steps_remaining:
                        type: integer
                        example: 3
                      estimated_total_time:
                        type: string
                        example: 12 minutes
                      completion_rate_similar_users:
                        type: integer
                        description: Completion rate percentage for similar users
                        example: 87
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Step not accessible
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/onboarding/steps/{step_name}/validate":
    post:
      tags:
      - Onboarding
      summary: Validate step data
      description: |
        Validates onboarding step data without saving it. Useful for real-time
        form validation in mobile apps before final submission.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Step-specific data to validate
              example:
                user:
                  first_name: John
                  last_name: Doe
                  email: john.doe@example.com
      responses:
        '200':
          description: Validation successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Validation successful
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: false
                  errors:
                    type: object
                    description: Field-specific validation errors
                    example:
                      first_name:
                      - First name is required
                      email:
                      - Please enter a valid email address
                  warnings:
                    type: array
                    description: Non-blocking validation warnings
                    items:
                      type: string
                    example:
                    - Consider adding a phone number for better communication
  "/onboarding/reset":
    post:
      tags:
      - Onboarding
      summary: Reset onboarding progress
      description: |
        Resets the user's onboarding progress back to the beginning. This will
        clear all completed steps and preferences. Requires confirmation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - confirm
              properties:
                confirm:
                  type: string
                  description: Must be "true" to confirm reset
                  example: 'true'
      responses:
        '200':
          description: Onboarding reset successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  reset:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Onboarding has been reset successfully
                  current_step:
                    type: string
                    example: profile_completion
                  next_action:
                    type: string
                    example: "/api/v1/onboarding/steps/profile_completion"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/forms":
    get:
      tags:
      - Forms
      summary: List published forms (Published Forms page)
      description: "Returns all published forms for the business, matching exactly
        what appears\non the web **Published Forms** page (`/apps/forms/published`).\n\n-
        Ordered alphabetically by name (A→Z)\n- Excludes survey-category templates
        and forms linked to a Survey\n- No per-user permission filtering — all authenticated
        users see all published forms\n- Each item includes `page_count` (number of
        pages in the form)\n\n**\U0001F4CB Complete Forms API**: For full Forms App
        API documentation including\ntemplate management, public sharing, and integration
        endpoints, see the\ndedicated Forms App schema at `/api-docs/forms-app-schema.yaml`\n"
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Case-insensitive substring match on form name
        schema:
          type: string
          example: incident report
      - name: category
        in: query
        required: false
        description: Filter by form category slug
        schema:
          type: string
          example: safety
      - name: priority
        in: query
        required: false
        description: Filter by form priority
        schema:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          example: high
      - name: page
        in: query
        required: false
        description: Page number for pagination (default 1)
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, 1–50 (default 20)
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Published forms retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/FormSummaryItem"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/forms/my_submissions":
    get:
      tags:
      - Forms
      summary: List current user's form submissions (My Submissions tab)
      description: |
        Returns the authenticated user's own form submissions, matching the web
        **My Submissions** page (`/apps/forms/my_submissions`).

        - Ordered by `updated_at DESC` — most recently touched first
        - Scope: only submissions belonging to the current user and business
        - The `status` parameter maps to the 6 tabs in the mobile UI:

        | `status` value | Mobile tab |
        |---|---|
        | _(omit)_ or `all` | All Statuses |
        | `draft` | Saved Drafts |
        | `submitted` | Submitted |
        | `under_review` | Under Review |
        | `approved` | Approved |
        | `rejected` | Rejected |
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: |
          Status tab filter. Omit or pass `all` for every status.
          Allowed values: all, draft, submitted, under_review, approved, rejected.
        schema:
          type: string
          enum:
          - all
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          example: draft
      - name: search
        in: query
        required: false
        description: Case-insensitive substring match on form name
        schema:
          type: string
          example: safety audit
      - name: page
        in: query
        required: false
        description: Page number (default 1)
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, 1–50 (default 20)
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Submissions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MySubmissionItem"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
                  segment_counts:
                    type: object
                    description: Per-status tab badge counts. Present ONLY on the
                      all view (status=all or omitted); absent when a specific status
                      is requested. Counts honor the active `search` filter. `all`
                      is the grand total across every status and may exceed the sum
                      of the five buckets when submissions sit in a status without
                      its own tab (e.g. pending_completion).
                    properties:
                      all:
                        type: integer
                        example: 7
                      draft:
                        type: integer
                        example: 1
                      submitted:
                        type: integer
                        example: 2
                      under_review:
                        type: integer
                        example: 1
                      approved:
                        type: integer
                        example: 2
                      rejected:
                        type: integer
                        example: 1
                    required:
                    - all
                    - draft
                    - submitted
                    - under_review
                    - approved
                    - rejected
              examples:
                all_statuses:
                  summary: All statuses (default)
                  value:
                    items:
                    - id: 1042
                      form_id: 7
                      form_name: Equipment Transfer
                      form_description: Transfer equipment custody between sites
                      form_category: operations
                      status: draft
                      status_label: Saved draft
                      completion_percentage: 85
                      submitted_at:
                      reviewed_at:
                      review_notes:
                      updated_at: '2026-06-11T16:30:00Z'
                      created_at: '2026-06-10T08:00:00Z'
                      is_offline_submission: false
                    - id: 2232
                      form_id: 12
                      form_name: Site Safety Audit
                      form_description: Quarterly site-level safety walkthrough
                      form_category: safety
                      status: submitted
                      status_label: Submitted
                      completion_percentage: 100
                      submitted_at: '2026-06-12T09:52:00Z'
                      reviewed_at:
                      review_notes:
                      updated_at: '2026-06-12T09:52:00Z'
                      created_at: '2026-06-12T09:00:00Z'
                      is_offline_submission: false
                    meta:
                      total_count: 6
                      current_page: 1
                      total_pages: 1
                      per_page: 20
                    segment_counts:
                      all: 6
                      draft: 1
                      submitted: 2
                      under_review: 1
                      approved: 1
                      rejected: 1
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Invalid status value
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: invalid_status
                  message:
                    type: string
                    example: 'status must be one of: draft, submitted, under_review,
                      approved, rejected'
  "/forms/assignments":
    get:
      tags:
      - Forms
      summary: List the caller's open recurring-form assignments
      description: |
        Returns the current user's open form assignments — the mobile **Assigned**
        tab. Caller-scoped: never another user's assignments, never tenant-wide.

        **Derived, not stored.** There is no per-user assignment row in the Forms
        schema. A scheduled form only sends notifications when it runs; it does not
        write an assignment record or a `pending_completion` submission. An
        "assignment" here is therefore computed as an active schedule that names the
        caller, whose template is still accepting submissions, and whose audience
        still includes the caller — joined to the caller's own draft for that
        template.

        **Stale assignee lists are filtered out.** A schedule's assignee list is an
        admin-authored blob written when the schedule was saved and never
        revalidated, so it still names people who have since moved out of the target
        department, location, or group. This endpoint applies the **same
        delivery-time audience re-check** the scheduling job applies, so it lists
        only assignments the job itself would actually deliver.

        Excluded: paused schedules, schedules past their end date, and templates that
        are archived, unpublished, closed by schedule, or at their response cap.

        `403` when the Forms app is not enabled or not licensed for the tenant,
        matching the sibling Forms reads.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50.
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: The caller's open assignments
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          description: Scheduled-form id — the assignment's identity.
                        form_id:
                          type: integer
                        form_name:
                          type: string
                        form_description:
                          type: string
                          nullable: true
                        form_category:
                          type: string
                          nullable: true
                        priority:
                          type: string
                          nullable: true
                        page_count:
                          type: integer
                        field_count:
                          type: integer
                        required_field_count:
                          type: integer
                        schedule_descriptor:
                          type: string
                          description: Human cadence, e.g. "Scheduled · every 2 weeks".
                          example: Scheduled · weekly
                        due_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: |
                            When the current cycle closes. The schema holds no
                            per-occurrence due date, so this is the schedule's next
                            reset — the deadline for "the check you do this cycle".
                            The campaign's terminal bound is `ends_at`, kept separate
                            so a weekly form is not reported as due months away.
                        next_run_at:
                          type: string
                          format: date-time
                          nullable: true
                        last_run_at:
                          type: string
                          format: date-time
                          nullable: true
                        ends_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Schedule's terminal bound, or null when open-ended.
                        draft_submission_id:
                          type: integer
                          nullable: true
                          description: The caller's draft for this template, or null.
                        completion_percentage:
                          type: integer
                          nullable: true
                          description: Null when not started.
                        status:
                          type: string
                          enum:
                          - due_today
                          - assigned_to_you
                          - action_needed
                          description: |
                            `action_needed` when a draft exists (that outranks the
                            date), else `due_today` when the cycle closes today,
                            else `assigned_to_you`.
                        action:
                          type: string
                          enum:
                          - start
                          - continue_draft
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      current_page:
                        type: integer
                      total_pages:
                        type: integer
                      per_page:
                        type: integer
              example:
                items:
                - id: 73
                  form_id: 799
                  form_name: Employee Information Update
                  form_description: Request changes to your personal information on
                    file
                  form_category: hr
                  priority: normal
                  page_count: 1
                  field_count: 4
                  required_field_count: 0
                  schedule_descriptor: Scheduled · weekly
                  due_at: '2026-08-17T19:46:49Z'
                  next_run_at: '2026-08-17T19:46:49Z'
                  last_run_at: '2026-08-10T19:46:49Z'
                  ends_at: '2026-12-12T19:46:49Z'
                  draft_submission_id:
                  completion_percentage:
                  status: assigned_to_you
                  action: start
                meta:
                  total_count: 1
                  current_page: 1
                  total_pages: 1
                  per_page: 20
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/forms/categories":
    get:
      tags:
      - Forms
      summary: List form categories with published-form counts
      description: |
        Returns the canonical list of form categories (the same set the web form
        builder and Published Forms filter use), each with the number of published
        forms in that category for the caller's business.

        - The full canonical list is always returned, including categories with `form_count: 0`
        - Items follow the platform's defined category order (not sorted by count or name)
        - `form_count` uses the same exclusions as `GET /api/v1/forms`
          (published only; excludes survey-category and survey-linked forms), so a
          category's `form_count` equals the `meta.total_count` of
          `GET /api/v1/forms?category=<value>`
        - Categories are not tenant-configurable today
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/FormCategoryItem"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Number of categories returned (the full canonical
                          list)
                        example: 14
              examples:
                default:
                  summary: Categories with published-form counts
                  value:
                    items:
                    - value: general
                      label: General
                      form_count: 4
                    - value: safety
                      label: Safety
                      form_count: 2
                    - value: hr
                      label: HR
                      form_count: 6
                    - value: it
                      label: IT
                      form_count: 0
                    meta:
                      total_count: 14
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/forms/approvals":
    get:
      tags:
      - Forms
      summary: List submissions pending the current reviewer's approval
      description: |
        Returns form submissions awaiting review/approval — the mobile
        **Approvals** queue. Mirrors the web Submissions review queue.

        - **Reviewer-only.** The caller must be an admin, manager, or Forms App
          Admin in the business; everyone else gets `403`.
        - **What each reviewer sees differs by role:**
          - **Admins and Forms App Admins** see the business-wide
            **pending_review** set (statuses `submitted` and `under_review`).
          - **Managers** see that **same** status set, narrowed to submissions
            from their own **direct reports** (active members of the business who
            report to them). A manager who is also an admin or Forms App Admin is
            treated as an admin (unrestricted).
        - The multi-step approval-workflow model is not yet wired up, so there is
          no per-step / per-approver assignment beyond this role scoping.
        - Ordered **oldest-waiting-first** (longest in the queue at the top).
        - The home-screen pending-approval badge (`GET /api/v1/home` →
          `forms.pending_approval_count`) counts exactly this same per-role set.

        **Filters.** All are optional and compose with `AND`; omitting every one
        returns the unfiltered queue. They narrow the caller's already-authorized
        queue and can never widen it — `?user_id=` on a manager's queue can only
        pick one of their own reportees. A value that cannot be a filter (an
        unparseable date, an array/hash-shaped `?template_id[a]=b`) is **dropped**
        from the query: the response is a `200` with that filter simply not
        applied, never a `500` and never a silently-empty list.
      security:
      - BearerAuth: []
      parameters:
      - name: overdue
        in: query
        required: false
        description: |
          `true` returns only submissions that have been awaiting review longer
          than the server-side review SLA (currently 7 days, measured on
          `submitted_at` falling back to `created_at`). Identical to the web
          review queue's Overdue filter. Do not hardcode the threshold on the
          client — read the per-item `overdue` flag instead.
        schema:
          type: boolean
      - name: template_id
        in: query
        required: false
        description: Only submissions of this form template
        schema:
          type: integer
      - name: user_id
        in: query
        required: false
        description: Only submissions from this submitter
        schema:
          type: integer
      - name: date_from
        in: query
        required: false
        description: |
          Inclusive start of the submitted-date window (`YYYY-MM-DD`), measured
          on the same clock as `submitted_at` (falling back to `created_at`).
        schema:
          type: string
          format: date
      - name: date_to
        in: query
        required: false
        description: Inclusive end of the submitted-date window (`YYYY-MM-DD`)
        schema:
          type: string
          format: date
      - name: search
        in: query
        required: false
        description: Case-insensitive match on the form name OR the submitter's name
        schema:
          type: string
      - name: page
        in: query
        required: false
        description: Page number (default 1)
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, 1–50 (default 20)
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Pending approvals retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ApprovalItem"
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/PaginationMeta"
                    - type: object
                      properties:
                        overdue_count:
                          type: integer
                          description: |
                            Overdue submissions in the whole filtered set (not just
                            this page) — badge the queue and populate an "Overdue"
                            tile from this one call.
                          example: 4
              examples:
                queue:
                  summary: Reviewer's pending queue
                  value:
                    items:
                    - id: 2231
                      reference: "#2231"
                      form_id: 7
                      form_name: Site Safety Audit
                      form_description: Quarterly site-level safety walkthrough
                      form_category: safety
                      priority: high
                      submitter: J. Rivera
                      submitter_email: jrivera@example.com
                      submitter_photo_url: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/.../avatar.jpg
                      status: under_review
                      status_label: Under review
                      requires_approval: true
                      overdue: true
                      submitted_at: '2026-06-18T09:00:00Z'
                      waiting_since: '2026-06-18T09:00:00Z'
                      updated_at: '2026-06-18T09:05:00Z'
                      created_at: '2026-06-18T08:40:00Z'
                    meta:
                      total_count: 4
                      current_page: 1
                      total_pages: 1
                      per_page: 20
                      overdue_count: 4
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/forms/{id}":
    get:
      tags:
      - Forms
      summary: Get form template for mobile rendering
      description: |
        Returns complete form template with fields optimized for mobile rendering.
        Includes a draft submission for offline support and mobile-specific configuration.

        The response includes all data needed to render the form offline and handle submissions.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      - name: submission_id
        in: query
        required: false
        description: Chooses the source for each field's `submission_data` value (always
          present in the response). When supplied, values come from THIS submission
          (for resume/edit) — scoped to the caller's OWN submissions of this form;
          a blank, missing, or other-user submission_id yields `null`. When omitted,
          values come from the caller's DRAFT submission for this form; if there is
          no draft data, every field's `submission_data` is `null`.
        schema:
          type: integer
          example: 789
      responses:
        '200':
          description: Form template retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Form data optimized for rendering
                properties:
                  template:
                    "$ref": "#/components/schemas/FormTemplate"
                  draft_submission:
                    "$ref": "#/components/schemas/FormSubmission"
                    nullable: true
                    description: 'Existing draft submission for progress restoration.
                      NOTE: the `submission_data` property is intentionally omitted
                      here — each field in `fields[]` now carries its own `submission_data`
                      (sourced from this draft or the requested submission), so the
                      map is not repeated on this object.'
                  mobile_config:
                    type: object
                    description: Mobile-specific rendering configuration
                    properties:
                      offline_capable:
                        type: boolean
                        description: Whether the form supports offline mode
                        example: true
                      voice_input_enabled:
                        type: boolean
                        description: Whether voice input is available
                        example: true
                      photo_capture_enabled:
                        type: boolean
                        description: Whether photo capture is available
                        example: true
                      gps_enabled:
                        type: boolean
                        description: Whether GPS location is required
                        example: false
                      estimated_time_minutes:
                        type: integer
                        description: Estimated completion time
                        example: 10
                  prefill_data:
                    type: object
                    description: Pre-filled field values
                    nullable: true
                    additionalProperties: true
                required:
                - template
                - mobile_config
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/forms/{id}/preview":
    get:
      tags:
      - Forms
      summary: Preview a form (lightweight metadata + field list)
      description: |
        Lightweight preview of a form: header metadata plus the ordered field
        list with **label and type only** — no field configuration, validation,
        options, draft, or per-user state, and (unlike `GET /forms/{id}`) it does
        NOT create a draft submission. Use it to show a form's shape before the
        user opens it to fill.

        Access is gated like `GET /forms/{id}` (the Forms app must be enabled for
        the business and visible to the user — the same access model as the web),
        so the field structure of an inaccessible form is not exposed.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Form preview retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 123
                  name:
                    type: string
                    example: Site Safety Audit
                  category:
                    type: string
                    nullable: true
                    example: operations
                  page_count:
                    type: integer
                    description: Number of pages (page_break count + 1)
                    example: 2
                  field_count:
                    type: integer
                    description: Total number of fields (equals the size of `fields`)
                    example: 12
                  estimated_time_minutes:
                    type: integer
                    description: Estimated time to complete, in minutes
                    example: 8
                  shareable_link:
                    type: string
                    nullable: true
                    description: 'Public share URL (`/f/{token}`) for the form, or
                      `null` when the form is not publicly accessible (not published,
                      sharing disabled, public access off, or no token issued).

                      '
                    example: https://officechat.workforce.mangoapps.com/f/abc123
                  qr_code_image_url:
                    type: string
                    nullable: true
                    description: 'Absolute URL of the form''s QR-code image (PNG),
                      encoding the public share link. Present only when the form is
                      publicly accessible (same gate as `shareable_link`); `null`
                      otherwise. Keyed by the form''s public access token (not its
                      numeric id) so a mobile client with no web session can load
                      it directly.

                      '
                    example: https://officechat.workforce.mangoapps.com/apps/forms/templates/Xlv6kQQt1RSiHKB0kQUsiPmLcVm_l2Cu/qr_code?format=png
                  fields:
                    type: array
                    description: 'All fields in display (position) order, each with
                      label and type only. No other field information is included.

                      '
                    items:
                      type: object
                      properties:
                        label:
                          type: string
                          nullable: true
                          description: Field label (null for unlabeled fields such
                            as page breaks)
                          example: Full Name
                        type:
                          type: string
                          description: Field type
                          example: text
                      required:
                      - type
                    example:
                    - label: Full Name
                      type: text
                    - label: Site
                      type: select
                    - label:
                      type: page_break
                    - label: Notes
                      type: textarea
                required:
                - id
                - name
                - page_count
                - field_count
                - estimated_time_minutes
                - fields
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/forms/{id}/submit":
    post:
      tags:
      - Forms
      summary: Submit completed form
      description: |
        Submit a completed form, either as a new submission or sync from offline mode.
        Supports both immediate submission and offline sync scenarios.

        For offline sync, include submission_id and set offline_sync=true.

        Media fields (file / image / video / audio / signature / gallery): direct-
        upload each file first via the standard ActiveStorage endpoint
        (POST /rails/active_storage/direct_uploads) to obtain a blob `signed_id`,
        then embed that `signed_id` in `submission_data` for the field — a string
        for a single field, an array for a gallery. The server validates all
        references (all-or-nothing, 422 on any bad/expired id or unsupported
        type/size), creates the file records, and rewrites those keys to a
        `{ uploaded, file_id, filename, content_type, file_size }` reference.
        Values already in reference form are left as-is.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      - name: submission_id
        in: query
        required: false
        description: Existing draft submission ID (for offline sync)
        schema:
          type: integer
          example: 456
      - name: offline_sync
        in: query
        required: false
        description: Indicates this is an offline sync operation
        schema:
          type: boolean
          default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission:
                  type: object
                  properties:
                    submission_data:
                      type: object
                      description: Form field values as key-value pairs
                      example:
                        employee_name: John Doe
                        incident_date: '2024-01-15'
                        description: Equipment malfunction in warehouse
                device_id:
                  type: string
                  description: Mobile device identifier
                  example: mobile-123abc
              required:
              - submission
      responses:
        '201':
          description: Form submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Form submitted successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  sync_status:
                    type: string
                    enum:
                    - immediate
                    - synced
                    example: immediate
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/forms/{id}/submissions":
    post:
      tags:
      - Forms
      summary: Create or update draft submission
      description: |
        Create a new draft submission or update an existing one for offline support.
        This endpoint allows mobile apps to save form progress locally and sync later.

        Pass `offline_sync=true` ONLY when saving a draft that was composed while
        the device was offline; an online client should omit it (or send false) so
        the draft is not mislabeled as an offline submission. On an existing draft
        the flag can only be turned on — an online edit never downgrades a draft
        that was genuinely created offline.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      - name: offline_sync
        in: query
        required: false
        description: Set true only when saving a draft created while offline. Defaults
          to online (false).
        schema:
          type: boolean
          default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission:
                  type: object
                  properties:
                    submission_data:
                      type: object
                      description: Partial form field values
                      example:
                        employee_name: John Doe
                        incident_date: '2024-01-15'
              required:
              - submission
      responses:
        '201':
          description: Draft submission created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft saved successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '200':
          description: Draft submission updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft saved successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/forms/submissions/{id}":
    delete:
      tags:
      - Forms
      summary: Delete a draft submission
      description: |
        Delete a form submission. Two gates apply, in order:

        1. **Owner-only** — only the user who CREATED the submission may delete
           it (a reviewer/admin cannot delete someone else's draft here) →
           `403` with error code `access_denied`.
        2. **Draft-only** — the submission must still be a `draft`. Once it is
           submitted / under_review / approved / rejected it is part of the
           review record and cannot be deleted → `422` with error code
           `cannot_delete` (the current `status` is returned in `error.details`).

        Deleting cascades to the submission's uploaded files. Requires the
        `write:forms` scope. (The same submission's GET and PATCH are also
        available at `/form_submissions/{id}`.)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Draft submission deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft submission deleted
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/form_submissions/{id}":
    get:
      tags:
      - Forms
      summary: Get submission details
      description: |
        Get details of a specific form submission including all field values and files.
        Used to view completed submissions and track submission status.

        Access mirrors the web Submissions surface: the submission's owner, or any
        admin / manager / Forms App Admin in the business, may view it. Any other
        user receives 403. Sensitive (manager-/submitter-restricted) field values
        are stripped from `submission_data` for viewers not permitted to see them.

        **submission_data shape (this endpoint only):** unlike the write/draft
        endpoints (which return a flat `field_name → value` map), the detail
        response expands each answer into an object carrying the field's `label`,
        submitted `value`, and `type` — `{ field_name: { label, value, type } }`.
        Keys are ordered by the template's field position (matching the web detail
        view), with any orphaned data (no matching field) appended last.
        The response's `submission.submitted_by` carries the submitter's name + email + photo_url.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Submission retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  submission:
                    allOf:
                    - "$ref": "#/components/schemas/FormSubmission"
                    - type: object
                      properties:
                        submitted_by:
                          type: object
                          nullable: true
                          description: 'Who submitted the form — name + email + photo.
                            Resolves to the respondent email (or "Anonymous User")
                            for anonymous submissions. photo_url is the submitter''s
                            thumbnail avatar (a ui-avatars fallback when no photo
                            is set), or null for anonymous submissions.

                            '
                          properties:
                            name:
                              type: string
                              nullable: true
                            email:
                              type: string
                              nullable: true
                            photo_url:
                              type: string
                              nullable: true
                        submission_data:
                          type: object
                          description: 'Per-field answers keyed by field_name. Each
                            value is an object with the field''s label, submitted
                            value, and type (the expanded detail shape, not the flat
                            write-endpoint map).

                            '
                          additionalProperties:
                            type: object
                            properties:
                              label:
                                type: string
                                nullable: true
                                description: Field label from the template definition
                              value:
                                nullable: true
                                description: 'Submitted value — a string or array
                                  for simple fields; a file-reference object/array
                                  for media fields (each reference includes a `url`
                                  to view/download the file); and, for `matrix` and
                                  Likert-mode `scale` fields, an ORDERED array of
                                  { label, value } pairs where `label` is the creator''s
                                  row/statement label and `value` is the chosen column/option
                                  label (an array of labels for a checkbox-grid or
                                  multiple-select matrix row). Pairs are returned
                                  in the creator''s configured row/statement order.
                                  A range-mode `scale` returns a plain scalar.

                                  '
                              type:
                                type: string
                                nullable: true
                                description: Field type (text, image, gallery, signature,
                                  select, …)
                          example:
                            site_photo:
                              label: Site photo
                              type: image
                              value:
                                uploaded: true
                                file_id: 36
                                filename: photo.png
                                content_type: image/png
                                file_size: 20480
                                url: "/rails/active_storage/blobs/redirect/eyJf…/photo.png"
                            notes:
                              label: Notes
                              type: textarea
                              value: All clear on the east wing.
                  form:
                    type: object
                    description: Summary information for a form template
                    properties:
                      id:
                        type: integer
                        description: Unique form template ID
                        example: 123
                      name:
                        type: string
                        description: Form name
                        example: Incident Report
                      description:
                        type: string
                        description: Form description
                        example: Report workplace incidents and safety concerns
                      category:
                        type: string
                        description: Form category
                        example: safety
                      priority:
                        type: string
                        enum:
                        - urgent
                        - high
                        - normal
                        - low
                        description: Form priority level
                        example: high
                      status:
                        type: string
                        enum:
                        - draft
                        - published
                        - archived
                        description: Template status
                        example: published
                      field_count:
                        type: integer
                        description: Total number of fields in the form
                        example: 8
                      required_field_count:
                        type: integer
                        description: Number of required fields
                        example: 5
                      estimated_time_minutes:
                        type: integer
                        description: Estimated completion time in minutes
                        example: 10
                      has_file_uploads:
                        type: boolean
                        description: Whether the form supports file uploads
                        example: true
                      sharing_enabled:
                        type: boolean
                        description: Whether the form can be shared publicly
                        example: false
                      requires_approval:
                        type: boolean
                        description: Whether submissions require approval
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: Form creation timestamp
                        example: '2024-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: Last update timestamp
                        example: '2024-01-15T10:00:00Z'
                    required:
                    - id
                    - name
                    - category
                    - priority
                    - status
                    - field_count
                    - estimated_time_minutes
                  timeline:
                    type: array
                    description: |
                      Status timeline shown on the web "View details" page. Always begins
                      with a "Form Submitted" event, followed by a single review event
                      reflecting the current state (approved / fields returned for
                      correction / rejected) when reviewed.
                    items:
                      "$ref": "#/components/schemas/SubmissionTimelineEvent"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Forbidden — not the owner and not an admin/manager/Forms App
            Admin
        '404':
          "$ref": "#/components/responses/NotFound"
    patch:
      tags:
      - Forms
      summary: Update draft submission
      description: |
        Update a draft form submission for offline editing and progress saving.
        Only draft submissions can be updated.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission:
                  type: object
                  properties:
                    submission_data:
                      type: object
                      description: Updated form field values
                      example:
                        employee_name: John Doe
                        incident_date: '2024-01-15'
                        description: Updated description
              required:
              - submission
      responses:
        '200':
          description: Draft submission updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft updated successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/form_submissions/{id}/approve":
    post:
      tags:
      - Forms
      summary: Approve a form submission
      description: |
        Approve a submission that is awaiting review, mirroring the web
        **Approval** flow (Approve action) and the native Approvals screen
        (swipe-right / "Approve").

        **Authorization:** reviewer only — the caller must be an admin, manager,
        or Forms App Admin for the business (`write:forms` scope). A plain member,
        *including the submission's own owner*, receives `403`.

        **Preconditions:**
        - The submission must be **awaiting review** (`submitted` or `under_review`),
          else `422` with code `invalid_state`.
        - Approval is blocked while any field is still returned to the employee for
          field-level correction (`422` with code `open_returns`).

        Sets `status = approved`, stamps `reviewed_by` / `reviewed_at`, and
        optionally records `review_notes`. The submitter is emailed a
        status-changed notification (best-effort).
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                review_notes:
                  type: string
                  description: Optional approval note recorded on the submission.
                  example: Approved — all required documents attached.
      responses:
        '200':
          description: Submission approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Submission approved
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — token lacks `write:forms` scope (`insufficient_permissions`)
            or the caller is not a reviewer (`access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            Submission is not awaiting review (`invalid_state`) or fields are still
            returned to the employee (`open_returns`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/reject":
    post:
      tags:
      - Forms
      summary: Reject a form submission
      description: |
        Reject a submission that is awaiting review, mirroring the web reject
        modal and the native reject-note bottom sheet (swipe-left / "Reject…").

        **Authorization:** reviewer only — the caller must be an admin, manager,
        or Forms App Admin for the business (`write:forms` scope). A plain member,
        *including the submission's own owner*, receives `403`.

        **Preconditions:**
        - `review_notes` (the rejection reason) is **required**; a missing or
          blank/whitespace-only value returns `422` with code `review_notes_required`.
          The note is shown to the submitter so they can correct and resubmit.
        - The submission must be **awaiting review** (`submitted` or `under_review`),
          else `422` with code `invalid_state`.

        Sets `status = rejected`, stamps `reviewed_by` / `reviewed_at`, and stores
        `review_notes`. The submitter is emailed a status-changed notification
        (best-effort).
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                review_notes:
                  type: string
                  description: Reason for rejection (required). Shown to the submitter.
                  example: The incident date is missing — please add it and resubmit.
              required:
              - review_notes
      responses:
        '200':
          description: Submission rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Submission rejected
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — token lacks `write:forms` scope (`insufficient_permissions`)
            or the caller is not a reviewer (`access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            `review_notes` is missing or blank (`review_notes_required`), or the
            submission is not awaiting review (`invalid_state`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/return_fields":
    post:
      tags:
      - Forms
      summary: Return individual fields for correction
      description: |
        The reviewer's **third option**, alongside approve and reject: return
        NAMED fields to the submitter with a per-field comment, instead of
        rejecting a whole submission over a couple of bad answers. The submitter
        then corrects only those fields — everything else stays locked and
        answered.

        Shares its implementation with the web reviewer action
        (`PATCH /apps/forms/submissions/:id/return_fields`) — same returnable-field
        rules, same preconditions, same notifications — so the two surfaces cannot
        drift on what a return does.

        **Authorization:** reviewer only — the caller must be an admin, manager
        (of the submitter), Forms App Admin, or the form's owner, and the token
        must carry `write:forms`. A plain member, *including the submission's own
        owner*, receives `403`. This is the same reviewer set the rest of this
        API applies to a submission (read, approve, reject); it does not include
        a user named as approver of the submission's current workflow step, who
        can act on it from the web only.

        **Preconditions:**
        - The submission must be **awaiting review** (`submitted` or `under_review`),
          else `422` with code `not_awaiting_review`.
        - The submission must have a submitter. An **anonymous** submission (public
          portal) has nobody to return fields to and returns `422` with code
          `anonymous_submission` — approve or reject it instead.
        - At least one **returnable** field must carry a **non-blank comment**,
          else `422` with code `no_returnable_fields`.

        **Fields that are never returnable** — dropped server-side, never applied,
        and named back in `ignored_fields`:
        - file / media fields (`file`, `image`, `video`, `audio`, `gallery`) and
          `annotation` — the corrections form cannot re-process an upload
        - any field this reviewer is not permitted to READ (field-level visibility)
        - any name that is not a field on the form
        - any field supplied with a blank comment

        **Effects:** `status` → `changes_requested`, `review_round` incremented by
        1, a `field_reviews` entry written per returned field (state / comment /
        round / history), and `reviewed_by` / `reviewed_at` stamped. The submitter
        receives an in-app *action required* notification **and** an email
        (best-effort — a delivery failure never fails the call).

        A subsequent `GET /api/v1/forms/{form_id}?submission_id={id}` reflects the
        new `returned_field_names`, each field's `field_review`, and
        `is_editable: false` on the fields that were not returned.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 4812
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                field_returns:
                  type: object
                  description: |
                    `{ field_name: comment }`. The comment is REQUIRED per field
                    and is shown to the submitter — a blank comment does not
                    return the field.
                  additionalProperties:
                    type: string
                  example:
                    incident_location: Please give the bay number, not just 'warehouse'.
                    witness_name: Left blank — required if anyone else was present.
                review_notes:
                  type: string
                  description: Optional submission-level note recorded on the submission.
                  example: Two fields need detail before I can approve.
              required:
              - field_returns
      responses:
        '200':
          description: Fields returned to the submitter for correction
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Returned 2 fields for correction
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  ignored_fields:
                    type: array
                    description: |
                      Names the request asked to return that the server dropped —
                      non-returnable, unknown, or supplied with a blank comment.
                      **Omitted entirely when nothing was dropped.** Same key and
                      same meaning as on the submission write paths.
                    items:
                      type: string
                    example:
                    - site_photo
                  warning:
                    type: string
                    description: Human-readable companion to `ignored_fields`; omitted
                      when nothing was dropped.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — token lacks `write:forms` scope (`insufficient_permissions`)
            or the caller is not a reviewer (`access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            Submission is not awaiting review (`not_awaiting_review`), was submitted
            anonymously (`anonymous_submission`), no returnable field carried a
            comment (`no_returnable_fields`, whose `details.ignored_fields` names
            what was dropped), or the return could not be saved (`return_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/marketplace_apps":
    get:
      tags:
      - Marketplace Apps
      summary: List enabled marketplace apps
      description: |
        Returns a list of marketplace apps that are enabled for the authenticated user's business.
        This endpoint provides all data needed to display a grid view of apps in a mobile client,
        including app icons, names, descriptions, and launch URLs.

        Apps are returned in display order (sort_order) and include configuration data
        specific to the business.
      parameters:
      - name: include_metadata
        in: query
        required: false
        description: Whether to include app metadata in response
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Enabled marketplace apps retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MarketplaceApp"
                  total_count:
                    type: integer
                    description: Total number of enabled apps
                    example: 5
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/core_apps":
    get:
      tags:
      - System Modules
      summary: List enabled core apps
      description: |
        Returns a list of core platform apps and their enabled status for the authenticated user's business.
        Core apps are fundamental platform features like Shifts & Scheduling, Time & Attendance, etc.
        that can be enabled or disabled at the business level.

        This endpoint provides all data needed to display core app status in a mobile client,
        including app icons, descriptions, feature lists, and direct URLs.
      responses:
        '200':
          description: Core apps status retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CoreApp"
                  total_count:
                    type: integer
                    description: Total number of core apps
                    example: 6
                  enabled_count:
                    type: integer
                    description: Number of enabled core apps
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/apps":
    get:
      tags:
      - Apps
      summary: List all apps (core and marketplace)
      description: |
        **Consolidated endpoint** that returns both core apps and marketplace apps in a single response.
        This is the recommended endpoint for mobile clients to build the app grid view.

        Core apps are fundamental platform features (Shifts & Scheduling, Time & Attendance, etc.)
        while marketplace apps are add-on features that can be enabled/disabled.

        **Features:**
        - Single API call instead of multiple requests
        - Unified sorting and filtering across all app types
        - Category-based organization
        - Type filtering (core, marketplace, or all)
        - Enabled/disabled filtering
        - **Mobile native app exclusion** via `exclude_mobile_native` parameter

        **Response includes:**
        - App icons (Font Awesome classes for mobile, with web_url fallback for custom SVG icons)
        - Launch URLs
        - Feature lists
        - Metadata
        - Category grouping

        **Icon Handling for Mobile:**
        All apps now return Font Awesome 5 icon classes in `icon.url` with `icon.type: "icon_class"`.
        For apps that have custom SVG icons, the original SVG path is available in `icon.web_url`
        for web clients that prefer to use the custom icons.

        **Mobile Native Apps:**
        Use `exclude_mobile_native=true` to exclude apps that are natively supported in mobile clients:
        - shift-marketplace
        - shifts_scheduling
        - time_attendance
        - leave_management
        - timesheets
      parameters:
      - name: type
        in: query
        required: false
        description: Filter apps by type (core, marketplace, or all)
        schema:
          type: string
          enum:
          - all
          - core
          - marketplace
          default: all
        example: all
      - name: category
        in: query
        required: false
        description: Filter apps by category (e.g., "HR Management", "Workforce Management")
        schema:
          type: string
        example: Workforce Management
      - name: enabled_only
        in: query
        required: false
        description: Whether to return only enabled apps
        schema:
          type: boolean
          default: true
        example: true
      - name: exclude_mobile_native
        in: query
        required: false
        description: |
          Exclude apps that are natively supported in mobile clients.
          When set to true, the following apps are excluded:
          - shift-marketplace
          - shifts_scheduling
          - time_attendance
          - leave_management
          - timesheets

          This is useful for mobile clients that already have native implementations
          of these core features and only need to display marketplace/web-based apps.
        schema:
          type: boolean
          default: false
        example: false
      - name: include_navigation
        in: query
        required: false
        description: |
          Opt into the **grouped, navigation-aware** response shape. Off by
          default so existing clients keep the flat legacy payload described
          above — this parameter changes the TOP-LEVEL shape of the response,
          it does not merely add a field.

          When `include_navigation=true`:

          * The response is split into `pinned_apps` (the apps pinned for this
            user) and `apps` (the remaining enabled apps), with `pinned_count`
            / `apps_count` / `total_count` / `categories`. `core_count`,
            `marketplace_count` and `enabled_count` are **not** returned in
            this mode.
          * Every app object carries `pinned`, `has_mobile_view`, `mobile_url`
            and a `navigation_items` array — the app's in-app navigation
            (its sidebar tabs / mobile TabBar), so a client can render an app's
            sub-pages without a second call per app.
          * `enabled_only` and `type` are **ignored**: pinned/unpinned lists
            come from the web sidebar's own role-aware source
            (`SidebarHelper#pinned_apps_for_user` / `#unpinned_enabled_apps`),
            which only ever yields enabled, user-accessible marketplace/core
            apps. `category` and `exclude_mobile_native` are still applied.
          * For mobile clients (native `User-Agent`), apps without a
            mobile-optimized view are dropped entirely, and each item's `path`
            is rewritten to its `/m/...` equivalent (see `navigation_items`).

          `navigation_items` is **role-aware and gated per app** — it contains
          only what the caller may actually open, so an item's absence is the
          authorization signal. Two examples of the gating, straight from the
          builders:

          * **Ideas** always returns `dashboard` (`/apps/ideas`) and
            `all_ideas` (`/apps/ideas/list`); `campaigns` appears unless the
            business explicitly disabled campaigns
            (`configuration["campaigns"] == false`); `review_queue`
            ("Reviews") appears **only** for members of a review panel — the
            workspace default panel or a campaign panel. Panel membership is
            the only grant: **admins get no bypass**, matching the web
            "My Review Queue" tab.
          * **Forms** always returns `my_submissions`; `approvals` appears only
            for reviewers (admin / manager / Forms App Admin) and carries a
            live `count` of pending approvals.
          * **Communications** returns a fixed four-item set with no gating:
            `dashboard` (`/apps/communications`), `feed`
            (`/apps/communications/feed`), `mail`
            (`/apps/communications/messages`) and `my_posts`
            (`/apps/communications/my-posts`). There is **no `notifications`
            item** — notifications are a platform surface the client owns and
            reads from `GET /api/v1/notifications`, not a Communications tab.
          * **Training** returns a fixed native-app bottom-tab set: the learner
            tabs `my_learning` ("My Learning", `/m/apps/training`),
            `catalog` ("Catalog", `/m/apps/training/catalog`) and
            `my_records` ("My Records", `/m/apps/training/certificates`)
            are always present; `my_team` ("My Team",
            `/m/apps/training/manager`) appears **only** for people-managers and
            admins/HR admins. The `my_learning` title is TENANT-RENAMEABLE —
            it is the Training app's `portal_title` setting, falling back to
            "My Learning" when the tenant has not set one, so it always matches
            the heading of the page the tab opens. Learning Paths is a course
            *type* inside Catalog, not a tab, so it is not a navigation item.
          * **Recognitions** always returns `dashboard` (`/recognition`),
            `feed` (`/recognition/feed`), `my_recognition`
            (`/recognition/my_recognition`) and `leaderboard`
            (`/recognition/leaderboard`), in that order. `programs`
            (`/recognition/programs`) appears unless the business turned award
            requests off (`enable_award_requests == false`); `awards`
            ("Award Cycles", `/recognition/award-cycles`) appears **only** when
            the business turned award cycles on (`enable_award_cycles == true`,
            off by default); `team` (`/recognition/manager`) appears **only**
            for recognition reviewers — business admins, Recognitions app
            admins, and anyone with direct reports. The app's admin surfaces
            (Analytics / Admin / Settings on the web rail) are deliberately not
            part of this mobile navigation, and `team` carries no badge count.
          * **Company Store** returns the native-app bottom-tab set:
            `dashboard` ("Dashboard", `/apps/company-store`), `catalog`
            ("Catalog", `/apps/company-store/catalog`), `orders` ("Orders",
            `/apps/company-store/orders`) and `balance` ("Points",
            `/apps/company-store/balance`) are always present, in that order;
            `approvals` ("Approvals",
            `/apps/company-store/manager/approvals`) appears **only** for
            redemption approvers — a member of the tenant's designated
            redemption-approver group, or a manager with direct reports when no
            such group is configured (a configured group supersedes the org
            chart, so a line manager outside it is not an approver; admins with
            direct reports are exempt from that supersession). A business admin
            with **no** direct reports is not an approver here — admins act on
            held orders through the admin orders queue instead. The rail's Cart
            tab (which appears only once a cart exists), its Team Budget action
            and its Admin / Settings dropdowns are not part of this mobile
            navigation, and `approvals` carries no badge count.
          * **Frontline Execution** returns a native/mobile bottom bar curated
            **per persona** (from the Frontline Execution prototype), not the
            full set of tabs a caller may open. The caller is resolved to one of
            three personas by role — `associate` (a plain member),
            `manager` (a manager who is not an admin), and `head_office` (a
            business admin/owner or the Frontline Execution app admin) — and
            each gets a fixed, ordered bar:

              * `associate` → `my_day` ("My Day",
                `/apps/frontline-execution/my-day`), `requests` ("Requests",
                `/apps/frontline-execution/requests`).
              * `manager` → `my_day`, `requests`, `day_sheet` ("Day Sheet",
                `/apps/frontline-execution/day-sheet`), `reviews` ("Reviews",
                `/apps/frontline-execution/reviews`), `coverage` ("Coverage",
                `/apps/frontline-execution/coverage`).
              * `head_office` → `campaigns` ("Campaigns",
                `/apps/frontline-execution/campaigns`), `coverage`, `requests`.

            (The prototype's five bars collapse to three because the Store /
            District / Regional manager tiers are all role=manager and are not
            distinguishable from this payload.) Each item is then gated by its
            tenant SURFACE TOGGLE, so a disabled surface never leaves a tab that
            403s: `my_day` / `day_sheet` / `reviews` require `enable_my_day`,
            `coverage` requires `enable_coverage`, and `requests` / `campaigns`
            require `enable_campaigns`. Icons and labels follow the prototype
            (`requests` = paper-plane, `coverage` = gauge). The web rail's
            Overview, Request queue, Calendar, People, Shared blockers,
            Analytics, Settings and Import surfaces are not part of this bar, and
            no item carries a badge count.
          * **Safety Hub** returns the per-persona navigation from the Safety
            Hub mobile prototype, in two zones. The **My** zone is every user's:
            `safety_hub_my_submitted` ("My Submitted",
            `/apps/safety-hub/submitted_by_me`) appears when either reporting
            module (incidents / observations) is on; `safety_hub_my_alerts`
            ("My Alerts", `/apps/safety-hub/alerts/my`) follows the
            `emergency_alerts_enabled` toggle; `safety_hub_my_permits`
            ("My Permits", `/apps/safety-hub/permits`, gated on
            `permits_enabled`) and `safety_hub_my_corrective_actions`
            ("My Corrective Actions", `/apps/safety-hub/corrective_actions`,
            gated on `incidents_enabled`) appear **only for non-managers** (a
            manager reaches the whole board from the Team zone, so emitting them
            too would be a second row to the same path); `safety_hub_knowledge_base`
            ("Knowledge Base", `/apps/safety-hub/knowledge_base`) is always
            present. The **Team** zone appears **only** for managers — a
            `manager_or_above?` user or the Safety Hub app admin — and holds
            `safety_hub_team_alerts` ("Team Alerts", `/apps/safety-hub/alerts`),
            `safety_hub_team_incidents` ("Team Incidents",
            `/apps/safety-hub/incidents`), `safety_hub_team_observations`
            ("Team Observations", `/apps/safety-hub/safety_observations`),
            `safety_hub_team_permits` ("Team Permits", `/apps/safety-hub/permits`,
            gated on `permits_enabled` — the whole permit board, the same index
            My Permits narrows for a non-manager) and
            `safety_hub_team_corrective_actions` ("Team Corrective Actions",
            `/apps/safety-hub/corrective_actions`), each gated by its module
            toggle. Keys are namespaced `safety_hub_*`; the app's compliance,
            certifications, toolbox-talks, campaigns and admin surfaces are not
            part of this navigation, and no item carries a badge count.

          Gotchas:

          * An app can legitimately return `navigation_items: []` — either it
            exposes no sub-navigation, or (on mobile) every tab it has was
            dropped for lacking a real `/m/` route. Build the tile to open
            `mobile_url` / `url` in that case.
          * Single-child collapse (mobile only): when an app would return
            exactly ONE navigable item that belongs to its own mobile route,
            the item is removed and `mobile_url` is repointed at it, so the
            tile opens that page directly instead of a one-row submenu.
          * `navigation_items` is best-effort per app: if the underlying tab
            builder raises, that app returns `[]` rather than failing the
            request.
        schema:
          type: boolean
          default: false
        example: true
      responses:
        '200':
          description: Apps retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    description: |
                      Array of apps (both core and marketplace).

                      With `include_navigation=true` this holds only the
                      **unpinned** enabled apps (the pinned ones move to
                      `pinned_apps`), and every entry carries `pinned`,
                      `has_mobile_view`, `mobile_url` and `navigation_items`.
                    items:
                      "$ref": "#/components/schemas/ConsolidatedApp"
                  pinned_apps:
                    type: array
                    description: |
                      The apps pinned for this user, in the same order the web
                      sidebar pins them. **Only present when
                      `include_navigation=true`.** Same item schema as `apps`,
                      with `pinned: true`.
                    items:
                      "$ref": "#/components/schemas/ConsolidatedApp"
                  pinned_count:
                    type: integer
                    description: Number of entries in `pinned_apps`. Only present
                      when `include_navigation=true`.
                    example: 4
                  apps_count:
                    type: integer
                    description: Number of entries in `apps` (unpinned). Only present
                      when `include_navigation=true`.
                    example: 18
                  total_count:
                    type: integer
                    description: |
                      Total number of apps returned. With
                      `include_navigation=true` this is
                      `pinned_count + apps_count`.
                    example: 12
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  core_count:
                    type: integer
                    description: Number of core apps in response
                    example: 6
                  marketplace_count:
                    type: integer
                    description: Number of marketplace apps in response
                    example: 6
                  enabled_count:
                    type: integer
                    description: Number of enabled apps
                    example: 9
                  categories:
                    type: array
                    description: List of unique categories across all apps
                    items:
                      type: string
                    example:
                    - HR Management
                    - Workforce Management
                    - Analytics
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
              examples:
                default:
                  summary: All apps (default)
                  value:
                    apps:
                    - id: shifts_scheduling
                      slug: shifts_scheduling
                      name: Shifts & Scheduling
                      description: Complete shift scheduling and management system
                      type: core
                      category: Workforce Management
                      enabled: true
                      icon:
                        url: fas fa-calendar-alt
                        type: icon_class
                      url: "/shifts"
                      sort_order: 1
                    - id: 123
                      slug: employee-performance-management
                      name: Employee Performance Management
                      description: Comprehensive performance management system
                      type: marketplace
                      category: performance
                      enabled: true
                      icon:
                        url: fas fa-chart-line
                        type: icon_class
                        web_url: "/assets/icons/employee-performance-management.svg"
                      url: "/apps/employee-performance-management"
                      sort_order: 10
                    total_count: 12
                    core_count: 6
                    marketplace_count: 6
                    enabled_count: 10
                    categories:
                    - HR Management
                    - Workforce Management
                    - performance
                core_only:
                  summary: Core apps only
                  value:
                    apps:
                    - id: shifts_scheduling
                      slug: shifts_scheduling
                      name: Shifts & Scheduling
                      type: core
                      enabled: true
                      icon:
                        url: fas fa-calendar-alt
                        type: icon_class
                    total_count: 6
                    core_count: 6
                    marketplace_count: 0
                    enabled_count: 6
                marketplace_only:
                  summary: Marketplace apps only
                  value:
                    apps:
                    - id: 123
                      slug: employee-performance-management
                      name: Employee Performance Management
                      type: marketplace
                      enabled: true
                      icon:
                        url: fas fa-chart-line
                        type: icon_class
                        web_url: "/assets/icons/employee-performance-management.svg"
                    total_count: 6
                    core_count: 0
                    marketplace_count: 6
                    enabled_count: 4
                exclude_mobile_native:
                  summary: Exclude mobile native apps
                  description: Response when exclude_mobile_native=true, excluding
                    shifts_scheduling, time_attendance, leave_management, timesheets
                  value:
                    apps:
                    - id: skills_certifications
                      slug: skills_certifications
                      name: Skills & Certifications
                      type: core
                      category: HR Management
                      enabled: true
                      icon:
                        url: fas fa-graduation-cap
                        type: icon_class
                    - id: compensation_management
                      slug: compensation_management
                      name: Compensation Management
                      type: core
                      category: HR Management
                      enabled: true
                      icon:
                        url: fas fa-dollar-sign
                        type: icon_class
                    - id: 123
                      slug: employee-performance-management
                      name: Employee Performance Management
                      type: marketplace
                      enabled: true
                      icon:
                        url: fas fa-chart-line
                        type: icon_class
                        web_url: "/assets/icons/employee-performance-management.svg"
                    total_count: 8
                    core_count: 2
                    marketplace_count: 6
                    enabled_count: 7
                    categories:
                    - HR Management
                    - performance
                include_navigation:
                  summary: Grouped format with per-app navigation (include_navigation=true)
                  description: |
                    Pinned/unpinned grouping with role-aware `navigation_items`.
                    This Ideas entry is what a NON-reviewer receives — the
                    `review_queue` ("Reviews") item is absent because the caller
                    belongs to no review panel.
                  value:
                    pinned_apps:
                    - id: 141
                      slug: ideas
                      name: Ideas
                      type: marketplace
                      category: Collaboration
                      enabled: true
                      pinned: true
                      icon:
                        url: fas fa-lightbulb
                        type: icon_class
                      url: "/apps/ideas"
                      has_mobile_view: true
                      mobile_url: "/m/apps/ideas"
                      navigation_items:
                      - key: dashboard
                        title: Dashboard
                        icon: fas fa-gauge
                        path: "/apps/ideas"
                      - key: all_ideas
                        title: Ideas
                        icon: fas fa-lightbulb
                        path: "/apps/ideas/list"
                      - key: campaigns
                        title: Campaigns
                        icon: fas fa-bullhorn
                        path: "/apps/ideas/campaigns"
                    apps:
                    - id: 152
                      slug: wikis
                      name: Wikis
                      type: marketplace
                      category: Knowledge
                      enabled: true
                      pinned: false
                      icon:
                        url: fas fa-book
                        type: icon_class
                      url: "/apps/wikis"
                      has_mobile_view: true
                      mobile_url: "/m/apps/wikis"
                      navigation_items:
                      - key: dashboard
                        title: Dashboard
                        icon: fas fa-gauge
                        path: "/apps/wikis"
                      - key: all_wikis
                        title: All wikis
                        icon: fas fa-list-ul
                        path: "/apps/wikis/browse"
                    - id: 168
                      slug: safety-hub
                      name: Safety Hub
                      type: marketplace
                      category: workplace-ops
                      enabled: true
                      pinned: false
                      icon:
                        url: fas fa-shield-halved
                        type: icon_class
                      url: "/apps/safety-hub"
                      has_mobile_view: true
                      mobile_url: "/m/apps/safety-hub"
                      navigation_items:
                      - key: safety_hub_my_submitted
                        title: My Submitted
                        icon: fas fa-clipboard-check
                        path: "/apps/safety-hub/submitted_by_me"
                      - key: safety_hub_my_alerts
                        title: My Alerts
                        icon: fas fa-bullhorn
                        path: "/apps/safety-hub/alerts/my"
                      - key: safety_hub_knowledge_base
                        title: Knowledge Base
                        icon: fas fa-book-open
                        path: "/apps/safety-hub/knowledge_base"
                      - key: safety_hub_team_alerts
                        title: Team Alerts
                        icon: fas fa-bullhorn
                        path: "/apps/safety-hub/alerts"
                      - key: safety_hub_team_incidents
                        title: Team Incidents
                        icon: fas fa-triangle-exclamation
                        path: "/apps/safety-hub/incidents"
                      - key: safety_hub_team_observations
                        title: Team Observations
                        icon: fas fa-eye
                        path: "/apps/safety-hub/safety_observations"
                      - key: safety_hub_team_permits
                        title: Team Permits
                        icon: far fa-file-lines
                        path: "/apps/safety-hub/permits"
                      - key: safety_hub_team_corrective_actions
                        title: Team Corrective Actions
                        icon: fas fa-clipboard-check
                        path: "/apps/safety-hub/corrective_actions"
                    total_count: 3
                    pinned_count: 1
                    apps_count: 2
                    categories:
                    - Collaboration
                    - Knowledge
                    - workplace-ops
                    unread_notification_count: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '422':
          description: Business context not available
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: No business context available
                  apps:
                    type: array
                    items: {}
                    example: []
                  total_count:
                    type: integer
                    example: 0
  "/shifts":
    get:
      tags:
      - Shifts
      summary: List shifts
      description: |
        Returns shifts with flexible filtering and parameter-based querying. Supports:
        - User-specific shifts (user_id=current for authenticated user)
        - Open shifts available for claiming (type=open)
        - Current active shifts (type=current)
        - Period-based filtering (today, week, upcoming, past)
        - **NEW: Marketplace filters**
          - `marketplace_available`: Shifts available in the marketplace that the user can claim
          - `marketplace_claimed`: Shifts the user has claimed from the marketplace
          - `marketplace_listed`: Shifts the user has listed in the marketplace
      parameters:
      - name: type
        in: query
        description: |
          Type of shifts to retrieve.

          **Standard Types:**
          - `assigned`: User's assigned shifts (default)
          - `open`: Open shifts available for claiming
          - `current`: Currently active shifts
          - `all`: All shifts (admin/manager view)

          **Marketplace Types:**
          - `marketplace_available`: Shifts in marketplace available for claiming (excludes user's own shifts)
          - `marketplace_claimed`: Shifts user has claimed from marketplace (identified by marketplace_pickup flag)
          - `marketplace_listed`: Shifts user has listed for others to claim
        schema:
          type: string
          enum:
          - assigned
          - open
          - current
          - all
          - marketplace_available
          - marketplace_claimed
          - marketplace_listed
          default: assigned
      - name: period
        in: query
        description: |
          Time period filter, evaluated against the shift's start/end time.

          `week` uses the TENANT'S configured week start (Settings → business
          hours), not a fixed Monday, so it matches what the web schedule shows.

          Supported with the default shift list, `type=assigned`,
          `type=marketplace_claimed` and `type=marketplace_listed`.
          Combining it with `type=open`, `type=current` or
          `type=marketplace_available` returns 400
          `period_status_filter_not_supported_for_type` — those views already
          return a fixed set (claimable upcoming shifts, or the shifts in
          progress right now) and silently ignoring the filter would ship a
          list the caller believes was narrowed.
        schema:
          type: string
          enum:
          - current
          - today
          - week
          - upcoming
          - past
      - name: user_id
        in: query
        description: User ID or 'current' for authenticated user's shifts
        schema:
          oneOf:
          - type: integer
          - type: string
            enum:
            - current
      - name: location_id
        in: query
        description: |
          Filter by specific location ID. For multiple locations, use location_ids[] instead.
          Also accepts comma-separated values (e.g., location_id=1,2,3) for backwards compatibility.
        schema:
          type: string
        example: '1'
      - name: location_ids[]
        in: query
        description: "Filter by multiple location IDs. Use this to fetch shifts across
          all locations \nassigned to a user. Can be passed as an array (location_ids[]=1&location_ids[]=2)
          \nor comma-separated (location_ids=1,2,3).\n"
        schema:
          type: array
          items:
            type: integer
        style: form
        explode: true
        example:
        - 1
        - 2
        - 3
      - name: scope
        in: query
        description: |
          Narrow the list to shifts belonging to the CALLING user's own
          locations, departments, or teams. Takes no ids — the scope is
          resolved server-side from the authenticated user, so a client never
          has to hold a synced copy of their memberships.

          A shift is in scope when EITHER
            (a) the shift carries one of the USER'S OWN ids for the dimension
                — it is at one of their locations, or it (or its SCHEDULE) is
                for one of their teams / departments, OR
            (b) the shift carries THAT DIMENSION AT ALL, and somebody
                assigned to it is one of the user's people.

          Both halves matter: (a) alone misses a shift your employee picked
          up at another site; (b) alone misses an UNASSIGNED shift at your
          own site, which has nobody on it to make it yours.

          The dimension gate on (b) is what makes the scope name mean what it
          says. `scope=team` returns TEAM shifts only: a shift grouped by
          LOCATION, or an ad-hoc shift with no grouping at all, is not
          returned just because a teammate happens to be rostered on it.
          Likewise `scope=department` returns only shifts that carry a
          department. The gate is a no-op for `scope=location`, where no
          shift can lack a location.

          `scope=team` means CUSTOM scheduling teams. The three group types
          each answer to their own scope name: `location` groups (auto-created
          per site) to `scope=location`, `department` groups (auto-created
          from an org-chart department) to `scope=department`, and `custom`
          groups to `scope=team`. Note a team built by hand and filtered to a
          department is stored as `custom`, so it answers to `scope=team`.

          Composes with `location_id` / `start_date` etc. rather than
          replacing them — every filter narrows. A user whose scope resolves
          to nothing receives an empty list, never the whole business.

          AVAILABLE TO EVERY ROLE, INCLUDING A PLAIN EMPLOYEE (2026-09-02).
          On the DEFAULT list a caller who is not entitled to a business-wide
          read normally receives only their own assigned shifts; passing a
          resolved `scope` lifts that to their own unit — their colleagues'
          shifts, plus unassigned shifts at the unit, plus a colleague's shift
          worked at another site (half (b)). This is parity with the web Team
          Calendar, which has served an employee the same roster since
          2026-07-09.

          The reach is the UNIT and never the tenant: the resolver bounds an
          employee's `location` dimension to their own assigned locations, and
          `team` / `department` to memberships they actually hold.

          TWO THINGS IT DOES NOT LIFT.
            * A token carrying only `read:own_shifts` is unaffected — it still
              receives own-assignment rows with or without `scope`. The
              widening requires `read:shifts` / `write:shifts` / `admin`, or a
              session, exactly as a business-wide read does.
            * `user_id=<someone else>` is still refused with 403. Reading your
              unit's week is not the same as reading one named colleague's
              schedule.

          Team and department are read from the shift's SCHEDULE as well as
          the shift's own columns, because tenants attach them to the
          schedule and only some of it is copied down. `scope=department`
          additionally matches department-flavoured scheduling groups, which
          is the only way an ORG-CHART department resolves at all — neither
          shifts nor schedules carry a column for one.

          Supported with the DEFAULT shift list and `type=open` only.
          Combining it with `type=assigned|current|marketplace_*`, or with
          `user_id=current`, returns 400 `scope_not_supported_for_type` —
          those views are already narrowed to the caller, and silently
          ignoring the filter would return a response whose
          `meta.user_scope` named a scope that was never applied.

          Responses include `meta.user_scope` describing what the scope
          resolved to. A caller that resolves to no locations, teams or
          departments receives an EMPTY list — the filter fails closed.
        schema:
          type: string
          enum:
          - location
          - department
          - team
        example: location
      - name: status
        in: query
        description: |
          Filter by shift status.

          Supported with the same types as `period` (see above); `type=open`,
          `type=current` and `type=marketplace_available` return 400
          `period_status_filter_not_supported_for_type`. On
          `type=marketplace_listed` this filters the LISTING status, not the
          shift status.

          NOTE: `open` is NOT a valid shift status — the model permits only
          scheduled / completed / cancelled / late_reported / absence_reported,
          so `?status=open` matches zero rows. Use `?type=open` to list shifts
          available for claiming.
        schema:
          type: string
          enum:
          - scheduled
          - completed
          - cancelled
          - late_reported
          - absence_reported
      - name: date
        in: query
        description: Filter shifts by specific date (YYYY-MM-DD)
        schema:
          type: string
          format: date
      - name: start_date
        in: query
        description: 'Lower bound on the shift''s START date (inclusive).

          '
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: |
          Upper bound on the shift's START date (inclusive) — i.e. `start_date`
          and `end_date` together select the shifts that BEGIN inside the
          window, which is what the web date filter does.

          This bound was previously applied to `end_time`, which silently
          excluded every open-ended (ad-hoc) shift — those have no end time —
          and every overnight shift that began inside the window but finished
          after it.
        schema:
          type: string
          format: date
      - name: include
        in: query
        description: |
          Additional data to include. Accepts a comma-separated string
          (`include=a,b`) or repeated bracket notation (`include[]=a&include[]=b`).

          `user_scope` adds a `meta.user_scope` object listing the caller's
          own locations, departments and teams. It is included automatically
          whenever `scope` is passed; request it explicitly to read the
          memberships WITHOUT filtering by them.

          `user_scope.departments` names the caller's DEPARTMENT TEAMS —
          scheduling groups of `group_type: "department"`, each rendered as
          `{ id, name, type: "department_team", group_type,
          organizational_department_id }`. These are the same objects a
          matched shift echoes in its own `scheduling_group`, so a client can
          reconcile the two by id. Use `organizational_department_id` to reach
          the org-chart department the team was derived from.

          CAVEAT: `scope=department` ALSO matches shifts on
          `shifts.location_department_id` / `schedule_locations.location_department_id`,
          and those matches have no entry in `user_scope.departments` — a
          shift can therefore be returned with no meta row explaining it, and
          a caller whose only department signal is a LocationDepartment sees
          `departments: []` alongside a non-empty shift list. Prior to
          2026-08-27 this array carried LocationDepartment and
          OrganizationalDepartment records instead; it was changed so the meta
          names the same objects the items do.

          Costs ~13 extra queries
          (measured), and is paid on EVERY page, so it is off by default for
          clients that page this endpoint in a loop.

          `scope_users` adds `meta.user_scope.user_ids` — the people the scope
          resolved to, i.e. the users who share the requested dimension with
          the caller. It answers "who are my colleagues for this scope", which
          is NOT the same question as `teammates`:

            teammates    the people assigned to each returned shift
            user_ids     everyone in my location / team / department, whether
                         or not they have a shift in this response

          So a shift can be returned for a person who is NOT in `user_ids`
          (it is at your location, but they are not one of your people), and
          `user_ids` can name someone with no shift in the response at all.

          Costs no extra queries on a `?scope=`d request — the filter already
          resolved that list to build its roster half.

          IDS ONLY, deliberately: hydrating names and avatars costs roughly
          two queries per user, so fetch the people themselves from
          `/api/v1/users` when you need more than an id.

          The list names the caller's colleagues when the RESPONSE ROWS are
          roster-wide, and is bounded to the caller's own id otherwise
          (`counts.users` then 1, or 0 when the scope resolved to nothing).
          Rows are roster-wide for a business-wide read, and — since
          2026-09-02 — for any role that passed a resolved `scope` on a
          credential permitted to read shifts.

          So the bound still applies to:
            * a caller on the DEFAULT list with no `scope`, whose rows are
              their own assignments, and
            * a caller of ANY role, manager and admin included, whose token
              carries only `read:own_shifts`.

          Unchanged property: the bound never depends on `type`. One resolver
          is memoized per request and shared by every handler, so `user_ids`
          is a function of the caller's entitlement and of whether a `scope`
          was passed — not of which `type` view ran. That matters for
          `type=open`, whose items are unassigned shifts rather than the
          caller's own.

          IT IS THE ROSTER, NOT AN INDEX OF ASSIGNEES. The list is "my
          people" — the members of the resolved unit. A shift returned by
          half (a) may be worked by somebody who does NOT belong to that unit
          (an outsider covering a shift at your site); their shift is in
          `items` and their id is deliberately NOT here. Read an assignee from
          the shift row or `include=teammates`, never by assuming this list
          covers every person you will encounter.

          Capped at 50 like the other `user_scope` arrays, with
          `counts.users` always EXACT and the shared `truncated` flag set when
          the array was cut. For an ADMIN on `scope=location` the list is
          tenant-sized, because an admin's location scope is every location in
          the business — read `counts.users` rather than the array length.
          For `scope=team` and `scope=department` it is membership-only and
          never role-widened. With no `scope`, `user_ids` is empty by
          definition.

          `attendance` REQUIRES `format=detailed` ON THIS ENDPOINT — verified
          over HTTP 2026-09-02. Each item's `attendance_records` array is
          emitted only by the detailed serializer, and this list defaults to
          `format=standard`, so `include=attendance` on its own answers 200
          with no attendance data and nothing in the response to say why —
          note the example value below includes `attendance`, so following it
          literally is what surfaces this. Pass
          `format=detailed&include=attendance`. `GET /api/v1/shifts/{id}`
          needs no such pairing — its `format` already defaults to
          `detailed`. `teammates` and `notes` are unaffected: they render at
          every format.
        schema:
          type: string
          example: teammates,notes,attendance,user_scope,scope_users
      - name: format
        in: query
        description: Response detail level
        schema:
          type: string
          enum:
          - minimal
          - standard
          - detailed
          default: standard
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: List of shifts
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        name:
                          type: string
                          example: Morning Shift
                        description:
                          type: string
                          nullable: true
                        start_time:
                          type: string
                          format: date-time
                          example: '2025-01-27T09:00:00Z'
                        end_time:
                          type: string
                          format: date-time
                          example: '2025-01-27T17:00:00Z'
                        formatted_date:
                          type: string
                          description: Human-readable date format
                          example: Monday, Jan 27
                        formatted_time:
                          type: string
                          description: Human-readable time range
                          example: 9:00 AM - 5:00 PM
                        status:
                          type: string
                          enum:
                          - scheduled
                          - completed
                          - cancelled
                          - open
                          example: scheduled
                        urgent:
                          type: boolean
                          description: Whether this shift is marked as urgent
                          example: false
                        needs_coverage:
                          type: boolean
                          description: Whether this shift needs coverage
                          example: false
                        can_list_for_coverage:
                          type: boolean
                          description: Whether the current user can list this shift
                            for coverage in the marketplace
                          example: true
                        is_already_listed:
                          type: boolean
                          description: Whether this shift is already listed in the
                            marketplace by the current user
                          example: false
                        required_users:
                          type: integer
                          description: Number of users required for this shift
                        location:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Downtown Store
                            address:
                              type: string
                              nullable: true
                            phone:
                              type: string
                              nullable: true
                        users:
                          type: array
                          items:
                            "$ref": "#/components/schemas/User"
                        available_actions:
                          type: array
                          description: Actions available to the current user for this
                            shift
                          items:
                            type: string
                            enum:
                            - view_details
                            - request_coverage
                            - report_lateness
                            - view_teammates
                            - claim
                          example:
                          - view_details
                          - request_coverage
                          - view_teammates
                        teammates:
                          type: array
                          description: Other users assigned to this shift (when include=teammates)
                          nullable: true
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                description: User ID
                              name:
                                type: string
                                description: User full name
                              job_title:
                                type: string
                                nullable: true
                                description: User's job title/role
                              profile_photo_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or avatar placeholder
                              profile_photo_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                        listed_by:
                          type: object
                          nullable: true
                          description: User who listed this shift in the marketplace
                            (teammate format, present for marketplace shifts)
                          properties:
                            id:
                              type: integer
                              description: User ID
                            name:
                              type: string
                              description: User full name
                            job_title:
                              type: string
                              nullable: true
                              description: User's job title/role
                            profile_photo_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                avatar placeholder
                            profile_photo_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        picked_by:
                          type: object
                          nullable: true
                          description: User who claimed/picked up this shift from
                            the marketplace (teammate format, present when shift was
                            claimed)
                          properties:
                            id:
                              type: integer
                              description: User ID
                            name:
                              type: string
                              description: User full name
                            job_title:
                              type: string
                              nullable: true
                              description: User's job title/role
                            profile_photo_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                avatar placeholder
                            profile_photo_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        marketplace_info:
                          type: object
                          nullable: true
                          description: Marketplace listing information (present when
                            shift has marketplace listing)
                          properties:
                            has_listing:
                              type: boolean
                              description: Whether this shift has an active marketplace
                                listing
                              example: true
                            listing_id:
                              type: integer
                              description: ID of the marketplace listing
                              example: 880
                            listing_status:
                              type: string
                              enum:
                              - open
                              - filled
                              - closed
                              - cancelled
                              description: Current status of the marketplace listing
                              example: open
                            listing_type:
                              type: string
                              enum:
                              - pickup
                              - trade_only
                              - both
                              description: Type of marketplace listing - pickup (direct
                                claim), trade_only (requires application/trade), or
                                both (accepts either)
                              example: pickup
                            price:
                              type: number
                              format: float
                              description: Price offered for the shift
                              example: 0.01
                            currency:
                              type: string
                              description: Currency code (e.g., USD)
                              example: USD
                            listed_by:
                              type: integer
                              description: User ID of the person who listed the shift
                                (legacy field, use listed_by object instead)
                              example: 1487
                            listed_at:
                              type: string
                              format: date-time
                              description: When the shift was listed in the marketplace
                              example: '2025-12-01T10:05:00Z'
                        notes:
                          type: string
                          nullable: true
                          description: Shift notes (when include=notes)
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                  meta:
                    allOf:
                    - type: object
                      description: Pagination metadata
                      properties:
                        total_count:
                          type: integer
                          description: Total number of items
                          example: 150
                        total_pages:
                          type: integer
                          description: Total number of pages
                          example: 6
                        current_page:
                          type: integer
                          description: Current page number
                          example: 1
                        per_page:
                          type: integer
                          description: Items per page
                          example: 25
                      required:
                      - total_count
                      - total_pages
                      - current_page
                      - per_page
                    - type: object
                      properties:
                        current_shift:
                          "$ref": "../openapi.yaml#/components/schemas/Shift"
                          nullable: true
                        user_timezone:
                          type: string
                          example: America/New_York
                        type:
                          type: string
                          example: open_shifts
              examples:
                my_shifts:
                  summary: My assigned shifts
                  value:
                    items:
                    - id: 123
                      name: Morning Shift
                      date: Monday, Jan 27
                      start_time: '2025-01-27T09:00:00Z'
                      end_time: '2025-01-27T17:00:00Z'
                      location:
                        id: 1
                        name: Downtown Store
                      status: scheduled
                      urgent: false
                      needs_coverage: false
                      available_actions:
                      - view_details
                      - request_coverage
                      - view_teammates
                    meta:
                      total_count: 15
                      current_page: 1
                      total_pages: 2
                      per_page: 25
                      current_shift:
                      user_timezone: America/New_York
                open_shifts:
                  summary: Open shifts available for claiming
                  value:
                    items:
                    - id: 456
                      name: Evening Shift
                      date: Tuesday, Jan 28
                      start_time: '2025-01-28T14:00:00Z'
                      end_time: '2025-01-28T22:00:00Z'
                      location:
                        id: 2
                        name: Mall Location
                      status: open
                      urgent: true
                      needs_coverage: true
                      available_actions:
                      - claim
                      - view_details
                    meta:
                      total_count: 5
                      type: open_shifts
                marketplace_available:
                  summary: Marketplace - Available shifts to claim
                  value:
                    items:
                    - id: 789
                      name: Weekend Shift
                      date: Saturday, Feb 1
                      start_time: '2025-02-01T10:00:00Z'
                      end_time: '2025-02-01T18:00:00Z'
                      formatted_date: Saturday, Feb 01
                      formatted_time: 10:00 AM - 06:00 PM
                      location:
                        id: 3
                        name: North Branch
                      status: scheduled
                      urgent: false
                      needs_coverage: true
                      available_actions:
                      - claim
                      - view_details
                      marketplace_info:
                        has_listing: true
                        listing_id: 456
                        price: 50.0
                        currency: USD
                        listed_by: 123
                        listed_at: '2025-01-15T10:00:00Z'
                      listed_by:
                        id: 123
                        name: Grace Kulin
                        job_title: Product Manager
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/IMG_0130.jpg
                    meta:
                      total_count: 8
                      current_page: 1
                      total_pages: 1
                      per_page: 25
                      type: marketplace_available
                marketplace_claimed:
                  summary: Marketplace - Shifts I've claimed
                  value:
                    items:
                    - id: 890
                      name: Evening Shift
                      date: Thursday, Jan 30
                      start_time: '2025-01-30T15:00:00Z'
                      end_time: '2025-01-30T23:00:00Z'
                      formatted_date: Thursday, Jan 30
                      formatted_time: 03:00 PM - 11:00 PM
                      location:
                        id: 4
                        name: South Branch
                      status: scheduled
                      urgent: false
                      needs_coverage: false
                      available_actions:
                      - view_details
                      - request_coverage
                      marketplace_info:
                        has_listing: true
                        listing_id: 457
                        price: 45.0
                        currency: USD
                        listed_by: 456
                        listed_at: '2025-01-20T14:00:00Z'
                      listed_by:
                        id: 456
                        name: John Smith
                        job_title: Shift Supervisor
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/profile.jpg
                      picked_by:
                        id: 1256
                        name: Grace Kulin
                        job_title: Product Manager
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/IMG_0130.jpg
                    meta:
                      total_count: 3
                      current_page: 1
                      total_pages: 1
                      per_page: 25
                      type: marketplace_claimed
                marketplace_listed:
                  summary: Marketplace - Shifts I've listed
                  value:
                    items:
                    - id: 901
                      name: Late Night Shift
                      date: Friday, Feb 7
                      start_time: '2025-02-07T22:00:00Z'
                      end_time: '2025-02-08T06:00:00Z'
                      formatted_date: Friday, Feb 07
                      formatted_time: 10:00 PM - 06:00 AM
                      location:
                        id: 1
                        name: Downtown Store
                      status: scheduled
                      urgent: false
                      needs_coverage: true
                      available_actions:
                      - view_details
                      marketplace_info:
                        has_listing: true
                        listing_id: 458
                        price: 75.0
                        currency: USD
                        listed_by: 789
                        listed_at: '2025-01-25T09:00:00Z'
                      listed_by:
                        id: 789
                        name: Sarah Johnson
                        job_title: Operations Manager
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/sarah.jpg
                    meta:
                      total_count: 2
                      current_page: 1
                      total_pages: 1
                      per_page: 25
                      type: marketplace_listed
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shifts/{id}":
    get:
      tags:
      - Shifts
      summary: Get shift details
      description: |
        Retrieves detailed information about a specific shift, including:
        - Basic shift information (name, times, location, status)
        - Detailed location information
        - Shift details (description, notes, staffing counts)
        - **Marketplace listing information** (when available)
          - `listing_type`: Type of listing (pickup, trade_only, both)
          - `listing_status`: Current status (open, filled, closed, cancelled)
          - Price, currency, and listing metadata
        - Available actions for the current user
        - Assignment capabilities
      parameters:
      - name: id
        in: path
        required: true
        description: Shift ID
        schema:
          type: integer
          example: 3901
      - name: format
        in: query
        required: false
        description: Response format type
        schema:
          type: string
          enum:
          - minimal
          - standard
          - detailed
          default: detailed
      - name: include
        in: query
        required: false
        description: Additional fields to include (comma-separated)
        schema:
          type: string
          example: teammates,notes,attendance
      responses:
        '200':
          description: Shift details retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    id:
                      type: integer
                      example: 123
                    name:
                      type: string
                      example: Morning Shift
                    description:
                      type: string
                      nullable: true
                    start_time:
                      type: string
                      format: date-time
                      example: '2025-01-27T09:00:00Z'
                    end_time:
                      type: string
                      format: date-time
                      example: '2025-01-27T17:00:00Z'
                    formatted_date:
                      type: string
                      description: Human-readable date format
                      example: Monday, Jan 27
                    formatted_time:
                      type: string
                      description: Human-readable time range
                      example: 9:00 AM - 5:00 PM
                    status:
                      type: string
                      enum:
                      - scheduled
                      - completed
                      - cancelled
                      - open
                      example: scheduled
                    urgent:
                      type: boolean
                      description: Whether this shift is marked as urgent
                      example: false
                    needs_coverage:
                      type: boolean
                      description: Whether this shift needs coverage
                      example: false
                    can_list_for_coverage:
                      type: boolean
                      description: Whether the current user can list this shift for
                        coverage in the marketplace
                      example: true
                    is_already_listed:
                      type: boolean
                      description: Whether this shift is already listed in the marketplace
                        by the current user
                      example: false
                    required_users:
                      type: integer
                      description: Number of users required for this shift
                    location:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 1
                        name:
                          type: string
                          example: Downtown Store
                        address:
                          type: string
                          nullable: true
                        phone:
                          type: string
                          nullable: true
                    users:
                      type: array
                      items:
                        "$ref": "#/components/schemas/User"
                    available_actions:
                      type: array
                      description: Actions available to the current user for this
                        shift
                      items:
                        type: string
                        enum:
                        - view_details
                        - request_coverage
                        - report_lateness
                        - view_teammates
                        - claim
                      example:
                      - view_details
                      - request_coverage
                      - view_teammates
                    teammates:
                      type: array
                      description: Other users assigned to this shift (when include=teammates)
                      nullable: true
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            description: User ID
                          name:
                            type: string
                            description: User full name
                          job_title:
                            type: string
                            nullable: true
                            description: User's job title/role
                          profile_photo_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              avatar placeholder
                          profile_photo_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                    listed_by:
                      type: object
                      nullable: true
                      description: User who listed this shift in the marketplace (teammate
                        format, present for marketplace shifts)
                      properties:
                        id:
                          type: integer
                          description: User ID
                        name:
                          type: string
                          description: User full name
                        job_title:
                          type: string
                          nullable: true
                          description: User's job title/role
                        profile_photo_url:
                          type: string
                          nullable: true
                          description: Full-size profile photo URL (200x200) or avatar
                            placeholder
                        profile_photo_thumbnail_url:
                          type: string
                          nullable: true
                          description: Thumbnail profile photo URL (40x40) optimized
                            for list views
                    picked_by:
                      type: object
                      nullable: true
                      description: User who claimed/picked up this shift from the
                        marketplace (teammate format, present when shift was claimed)
                      properties:
                        id:
                          type: integer
                          description: User ID
                        name:
                          type: string
                          description: User full name
                        job_title:
                          type: string
                          nullable: true
                          description: User's job title/role
                        profile_photo_url:
                          type: string
                          nullable: true
                          description: Full-size profile photo URL (200x200) or avatar
                            placeholder
                        profile_photo_thumbnail_url:
                          type: string
                          nullable: true
                          description: Thumbnail profile photo URL (40x40) optimized
                            for list views
                    marketplace_info:
                      type: object
                      nullable: true
                      description: Marketplace listing information (present when shift
                        has marketplace listing)
                      properties:
                        has_listing:
                          type: boolean
                          description: Whether this shift has an active marketplace
                            listing
                          example: true
                        listing_id:
                          type: integer
                          description: ID of the marketplace listing
                          example: 880
                        listing_status:
                          type: string
                          enum:
                          - open
                          - filled
                          - closed
                          - cancelled
                          description: Current status of the marketplace listing
                          example: open
                        listing_type:
                          type: string
                          enum:
                          - pickup
                          - trade_only
                          - both
                          description: Type of marketplace listing - pickup (direct
                            claim), trade_only (requires application/trade), or both
                            (accepts either)
                          example: pickup
                        price:
                          type: number
                          format: float
                          description: Price offered for the shift
                          example: 0.01
                        currency:
                          type: string
                          description: Currency code (e.g., USD)
                          example: USD
                        listed_by:
                          type: integer
                          description: User ID of the person who listed the shift
                            (legacy field, use listed_by object instead)
                          example: 1487
                        listed_at:
                          type: string
                          format: date-time
                          description: When the shift was listed in the marketplace
                          example: '2025-12-01T10:05:00Z'
                    notes:
                      type: string
                      nullable: true
                      description: Shift notes (when include=notes)
                    created_at:
                      type: string
                      format: date-time
                    updated_at:
                      type: string
                      format: date-time
                - type: object
                  properties:
                    can_list_for_coverage:
                      type: boolean
                      description: Whether the current user can list this shift for
                        coverage
                      example: true
                    is_already_listed:
                      type: boolean
                      description: Whether this shift is already listed by the current
                        user
                      example: false
              examples:
                shift_with_marketplace_listing:
                  summary: Shift with marketplace listing
                  value:
                    id: 3901
                    name: Full Day Coverage - Pune Office
                    start_time: '2025-12-06T04:30:00Z'
                    end_time: '2025-12-06T13:00:00Z'
                    formatted_date: Friday, Dec 06
                    formatted_time: 04:30 AM - 01:00 PM
                    location:
                      id: 42
                      name: Pune Office
                    status: scheduled
                    urgent: false
                    needs_coverage: false
                    available_actions:
                    - view_details
                    - request_coverage
                    marketplace_info:
                      has_listing: true
                      listing_id: 880
                      listing_status: open
                      listing_type: pickup
                      price: '0.01'
                      currency: USD
                      listed_by: 1487
                      listed_at: '2025-12-01T10:05:00Z'
                    listed_by:
                      id: 1487
                      name: John Doe
                      job_title: Operations Manager
                      profile_photo_url: https://hub.workforce.mangoapps.com/avatar/1487
                    location_details:
                      id: 42
                      name: Pune Office
                      address: 123 Tech Park, Pune
                      phone: "+91-20-12345678"
                    shift_details:
                      description: Regular operations shift
                      notes: Remember to bring your access card
                      required_staff_count: 5
                      current_staff_count: 4
                    can_list_for_coverage: false
                    is_already_listed: true
                shift_without_marketplace:
                  summary: Shift without marketplace listing
                  value:
                    id: 767
                    name: Operations - Early Shift
                    start_time: '2025-09-23T14:00:00Z'
                    end_time: '2025-09-23T22:30:00Z'
                    formatted_date: Tuesday, Sep 23
                    formatted_time: 02:00 PM - 10:30 PM
                    location:
                      id: 265
                      name: Issaquah Office
                    status: scheduled
                    urgent: false
                    needs_coverage: false
                    available_actions:
                    - view_details
                    location_details:
                      id: 265
                      name: Issaquah Office
                      address: 1495 11th Avenue Northwest
                      phone: ''
                    shift_details:
                      description: ''
                      notes:
                      required_staff_count: 4
                      current_staff_count: 4
                    can_list_for_coverage: false
                    is_already_listed: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Not authorized to access this shift
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error: Not authorized to access this shift
        '404':
          description: Shift not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: shift_not_found
                  message: Shift not found
  "/shifts/{id}/claim":
    post:
      tags:
      - Shifts
      summary: Claim an open shift
      description: Employee claims an available open shift
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Shift claimed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Shift claimed successfully
                  shift:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 123
                      name:
                        type: string
                        example: Morning Shift
                      description:
                        type: string
                        nullable: true
                      start_time:
                        type: string
                        format: date-time
                        example: '2025-01-27T09:00:00Z'
                      end_time:
                        type: string
                        format: date-time
                        example: '2025-01-27T17:00:00Z'
                      formatted_date:
                        type: string
                        description: Human-readable date format
                        example: Monday, Jan 27
                      formatted_time:
                        type: string
                        description: Human-readable time range
                        example: 9:00 AM - 5:00 PM
                      status:
                        type: string
                        enum:
                        - scheduled
                        - completed
                        - cancelled
                        - open
                        example: scheduled
                      urgent:
                        type: boolean
                        description: Whether this shift is marked as urgent
                        example: false
                      needs_coverage:
                        type: boolean
                        description: Whether this shift needs coverage
                        example: false
                      can_list_for_coverage:
                        type: boolean
                        description: Whether the current user can list this shift
                          for coverage in the marketplace
                        example: true
                      is_already_listed:
                        type: boolean
                        description: Whether this shift is already listed in the marketplace
                          by the current user
                        example: false
                      required_users:
                        type: integer
                        description: Number of users required for this shift
                      location:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 1
                          name:
                            type: string
                            example: Downtown Store
                          address:
                            type: string
                            nullable: true
                          phone:
                            type: string
                            nullable: true
                      users:
                        type: array
                        items:
                          "$ref": "#/components/schemas/User"
                      available_actions:
                        type: array
                        description: Actions available to the current user for this
                          shift
                        items:
                          type: string
                          enum:
                          - view_details
                          - request_coverage
                          - report_lateness
                          - view_teammates
                          - claim
                        example:
                        - view_details
                        - request_coverage
                        - view_teammates
                      teammates:
                        type: array
                        description: Other users assigned to this shift (when include=teammates)
                        nullable: true
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              description: User ID
                            name:
                              type: string
                              description: User full name
                            job_title:
                              type: string
                              nullable: true
                              description: User's job title/role
                            profile_photo_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                avatar placeholder
                            profile_photo_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                      listed_by:
                        type: object
                        nullable: true
                        description: User who listed this shift in the marketplace
                          (teammate format, present for marketplace shifts)
                        properties:
                          id:
                            type: integer
                            description: User ID
                          name:
                            type: string
                            description: User full name
                          job_title:
                            type: string
                            nullable: true
                            description: User's job title/role
                          profile_photo_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              avatar placeholder
                          profile_photo_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      picked_by:
                        type: object
                        nullable: true
                        description: User who claimed/picked up this shift from the
                          marketplace (teammate format, present when shift was claimed)
                        properties:
                          id:
                            type: integer
                            description: User ID
                          name:
                            type: string
                            description: User full name
                          job_title:
                            type: string
                            nullable: true
                            description: User's job title/role
                          profile_photo_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              avatar placeholder
                          profile_photo_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      marketplace_info:
                        type: object
                        nullable: true
                        description: Marketplace listing information (present when
                          shift has marketplace listing)
                        properties:
                          has_listing:
                            type: boolean
                            description: Whether this shift has an active marketplace
                              listing
                            example: true
                          listing_id:
                            type: integer
                            description: ID of the marketplace listing
                            example: 880
                          listing_status:
                            type: string
                            enum:
                            - open
                            - filled
                            - closed
                            - cancelled
                            description: Current status of the marketplace listing
                            example: open
                          listing_type:
                            type: string
                            enum:
                            - pickup
                            - trade_only
                            - both
                            description: Type of marketplace listing - pickup (direct
                              claim), trade_only (requires application/trade), or
                              both (accepts either)
                            example: pickup
                          price:
                            type: number
                            format: float
                            description: Price offered for the shift
                            example: 0.01
                          currency:
                            type: string
                            description: Currency code (e.g., USD)
                            example: USD
                          listed_by:
                            type: integer
                            description: User ID of the person who listed the shift
                              (legacy field, use listed_by object instead)
                            example: 1487
                          listed_at:
                            type: string
                            format: date-time
                            description: When the shift was listed in the marketplace
                            example: '2025-12-01T10:05:00Z'
                      notes:
                        type: string
                        nullable: true
                        description: Shift notes (when include=notes)
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                  assignment:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        example: assigned
        '400':
          description: Shift not available for claiming
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: Already assigned to this shift
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shifts/{id}/actions":
    post:
      tags:
      - Shifts
      summary: Perform shift-related actions
      description: "Perform various employee actions on shifts:\n- request_coverage:
        Request someone to cover this shift\n- report_lateness: Report that you'll
        be late\n- report_absence: Report absence from shift\n- cancel_request: Cancel
        a previous request\n\n**Note:** Use `action_type` parameter (preferred) or
        `action` parameter for the action. \nUsing `action_type` is recommended as
        `action` is a reserved Rails parameter.\n"
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action_type
              properties:
                action_type:
                  type: string
                  enum:
                  - request_coverage
                  - report_lateness
                  - report_absence
                  - cancel_request
                  description: Type of action to perform (preferred parameter)
                action:
                  type: string
                  enum:
                  - request_coverage
                  - report_lateness
                  - report_absence
                  - cancel_request
                  description: Type of action to perform (legacy, use action_type
                    instead)
                  deprecated: true
                reason:
                  type: string
                  description: Reason for the action
                minutes:
                  type: integer
                  description: Minutes late (for report_lateness)
              examples:
                report_lateness:
                  summary: Report lateness
                  value:
                    action_type: report_lateness
                    minutes: 15
                    reason: Traffic delay
                report_absence:
                  summary: Report absence
                  value:
                    action_type: report_absence
                    reason: Personal appointment
                request_coverage:
                  summary: Request coverage
                  value:
                    action_type: request_coverage
                    reason: Family emergency
      responses:
        '200':
          description: Action completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  reported_at:
                    type: string
                    format: date-time
        '400':
          description: Invalid action or parameters
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_feedbacks":
    get:
      tags:
      - Shifts
      - Feedback
      summary: List user's shift feedbacks
      description: |
        Get all shift feedbacks submitted by the authenticated user.
        Includes feedback details, shift information, and timestamps.

        WITHDRAWN submissions (see `DELETE /shift_feedbacks/{id}`) are excluded
        from both the list and `meta.stats`.
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: shift_id
        in: query
        schema:
          type: integer
        description: Filter by specific shift ID
      - name: include_stats
        in: query
        schema:
          type: boolean
        description: When true, the response `meta` carries a `stats` object for the
          caller's own feedback in this business (total, average rating, and the positive
          / neutral / negative / unrated counts, which sum to the total). These figures
          are always business-wide and are NOT narrowed by `shift_id` — combining
          the two returns one shift's `items` beside the caller's whole history in
          `meta.stats`.
      responses:
        '200':
          description: Shift feedbacks retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 789
                        attendance_record_id:
                          type: integer
                          example: 123
                          description: ID of the associated attendance record
                        user_id:
                          type: integer
                          example: 456
                          description: ID of the user who submitted feedback
                        rating:
                          type: integer
                          minimum: 1
                          maximum: 5
                          example: 4
                          description: Overall shift rating (1-5 stars)
                        feedback_text:
                          type: string
                          nullable: true
                          example: Great shift, but the workspace was a bit noisy.
                          description: Detailed feedback text (optional)
                        shift_difficulty:
                          type: integer
                          minimum: 1
                          maximum: 5
                          nullable: true
                          example: 3
                          description: How difficult was the shift (1=Easy, 5=Very
                            Hard)
                        would_work_again:
                          type: boolean
                          nullable: true
                          example: true
                          description: Would the user work this shift again
                        can_edit:
                          type: boolean
                          example: true
                          description: Whether the feedback can still be edited (within
                            24 hours)
                        edit_window_expires_at:
                          type: string
                          format: date-time
                          example: '2025-10-11T16:00:00Z'
                          description: When the edit window expires (24 hours from
                            submission)
                        created_at:
                          type: string
                          format: date-time
                          example: '2025-10-10T16:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2025-10-10T16:00:00Z'
                        attendance_record:
                          "$ref": "#/components/schemas/AttendanceRecord"
                        shift:
                          "$ref": "#/components/schemas/Shift"
                        user:
                          "$ref": "#/components/schemas/User"
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shifts
      - Feedback
      summary: Submit shift feedback
      description: |
        Submit feedback for a completed shift. Can only submit feedback for shifts that:
        - Have been completed (attendance record exists)
        - User was checked in and out
        - Don't already have feedback submitted

        RE-SUBMITTING AFTER A WITHDRAWAL restores the withdrawn submission in
        place: the response is the usual 201, carrying the SAME `id` as the
        withdrawn one, the newly posted content, and the ORIGINAL
        `submitted_at`. The 24-hour edit/withdraw window therefore runs from the
        FIRST submission and does not restart, and managers are not re-notified.
        Any field you do not send is reset to its default rather than inherited
        from the withdrawn submission. This is the ONLY way back in after a
        withdrawal: `eligible_shifts` and the in-app prompts do not re-offer the
        shift, so a client that withdrew must POST for it explicitly.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - attendance_record_id
              - rating
              properties:
                attendance_record_id:
                  type: integer
                  description: ID of the attendance record for the completed shift
                  example: 123
                rating:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Overall shift rating (1-5 stars)
                  example: 4
                feedback_text:
                  type: string
                  description: Optional detailed feedback text
                  example: Great shift, but the workspace was a bit noisy.
                  maxLength: 2000
                shift_difficulty:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: How difficult was the shift (1=Easy, 5=Very Hard)
                  example: 3
                would_work_again:
                  type: boolean
                  description: Would you work this shift again?
                  example: true
      responses:
        '201':
          description: Shift feedback created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Shift feedback submitted successfully
                  shift_feedback:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      attendance_record_id:
                        type: integer
                        example: 123
                        description: ID of the associated attendance record
                      user_id:
                        type: integer
                        example: 456
                        description: ID of the user who submitted feedback
                      rating:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 4
                        description: Overall shift rating (1-5 stars)
                      feedback_text:
                        type: string
                        nullable: true
                        example: Great shift, but the workspace was a bit noisy.
                        description: Detailed feedback text (optional)
                      shift_difficulty:
                        type: integer
                        minimum: 1
                        maximum: 5
                        nullable: true
                        example: 3
                        description: How difficult was the shift (1=Easy, 5=Very Hard)
                      would_work_again:
                        type: boolean
                        nullable: true
                        example: true
                        description: Would the user work this shift again
                      can_edit:
                        type: boolean
                        example: true
                        description: Whether the feedback can still be edited (within
                          24 hours)
                      edit_window_expires_at:
                        type: string
                        format: date-time
                        example: '2025-10-11T16:00:00Z'
                        description: When the edit window expires (24 hours from submission)
                      created_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      attendance_record:
                        "$ref": "#/components/schemas/AttendanceRecord"
                      shift:
                        "$ref": "#/components/schemas/Shift"
                      user:
                        "$ref": "#/components/schemas/User"
        '400':
          description: Invalid request (missing params, invalid rating, etc.)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Attendance record not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shift_feedbacks/{id}":
    get:
      tags:
      - Shifts
      - Feedback
      summary: Get specific shift feedback
      description: Retrieve details of a specific shift feedback
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Shift feedback ID
      responses:
        '200':
          description: Shift feedback retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  shift_feedback:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      attendance_record_id:
                        type: integer
                        example: 123
                        description: ID of the associated attendance record
                      user_id:
                        type: integer
                        example: 456
                        description: ID of the user who submitted feedback
                      rating:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 4
                        description: Overall shift rating (1-5 stars)
                      feedback_text:
                        type: string
                        nullable: true
                        example: Great shift, but the workspace was a bit noisy.
                        description: Detailed feedback text (optional)
                      shift_difficulty:
                        type: integer
                        minimum: 1
                        maximum: 5
                        nullable: true
                        example: 3
                        description: How difficult was the shift (1=Easy, 5=Very Hard)
                      would_work_again:
                        type: boolean
                        nullable: true
                        example: true
                        description: Would the user work this shift again
                      can_edit:
                        type: boolean
                        example: true
                        description: Whether the feedback can still be edited (within
                          24 hours)
                      edit_window_expires_at:
                        type: string
                        format: date-time
                        example: '2025-10-11T16:00:00Z'
                        description: When the edit window expires (24 hours from submission)
                      created_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      attendance_record:
                        "$ref": "#/components/schemas/AttendanceRecord"
                      shift:
                        "$ref": "#/components/schemas/Shift"
                      user:
                        "$ref": "#/components/schemas/User"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `app_not_enabled` / `app_access_denied` (the tenant has not
            enabled the app, or this user is outside its audience), or
            `insufficient_permissions` (the token lacks `read:shift_feedback`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `feedback_not_found` — no such feedback for the authenticated user
            in this business. A feedback belonging to ANOTHER user answers 404,
            not 403. A WITHDRAWN submission also answers 404.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    patch:
      tags:
      - Shifts
      - Feedback
      summary: Update shift feedback
      description: |
        Update a previously submitted shift feedback.
        Can only update within 24 hours of submission.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Shift feedback ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                rating:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Overall shift rating (1-5 stars)
                feedback_text:
                  type: string
                  description: Detailed feedback text
                  maxLength: 2000
                shift_difficulty:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: How difficult was the shift
                would_work_again:
                  type: boolean
                  description: Would you work this shift again?
      responses:
        '200':
          description: Shift feedback updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Shift feedback updated successfully
                  shift_feedback:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      attendance_record_id:
                        type: integer
                        example: 123
                        description: ID of the associated attendance record
                      user_id:
                        type: integer
                        example: 456
                        description: ID of the user who submitted feedback
                      rating:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 4
                        description: Overall shift rating (1-5 stars)
                      feedback_text:
                        type: string
                        nullable: true
                        example: Great shift, but the workspace was a bit noisy.
                        description: Detailed feedback text (optional)
                      shift_difficulty:
                        type: integer
                        minimum: 1
                        maximum: 5
                        nullable: true
                        example: 3
                        description: How difficult was the shift (1=Easy, 5=Very Hard)
                      would_work_again:
                        type: boolean
                        nullable: true
                        example: true
                        description: Would the user work this shift again
                      can_edit:
                        type: boolean
                        example: true
                        description: Whether the feedback can still be edited (within
                          24 hours)
                      edit_window_expires_at:
                        type: string
                        format: date-time
                        example: '2025-10-11T16:00:00Z'
                        description: When the edit window expires (24 hours from submission)
                      created_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      attendance_record:
                        "$ref": "#/components/schemas/AttendanceRecord"
                      shift:
                        "$ref": "#/components/schemas/Shift"
                      user:
                        "$ref": "#/components/schemas/User"
        '400':
          description: |
            `parameter_missing` — the request body is missing the
            `shift_feedback` object, or sends a non-scalar value for a scalar
            field. The expired edit window is a 403, not a 400.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `update_not_allowed` (more than 24 hours since
            submission), `feedback_collection_disabled`, `app_not_enabled` /
            `app_access_denied`, or `insufficient_permissions` (the token lacks
            `write:shift_feedback`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `feedback_not_found` — no such feedback for the authenticated user
            in this business. A feedback belonging to ANOTHER user answers 404,
            not 403.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Shifts
      - Feedback
      summary: Withdraw shift feedback
      description: |
        Withdraw a previously submitted shift feedback.
        Can only withdraw within 24 hours of submission.

        The submission is RETAINED, not erased: it is marked withdrawn and
        disappears from every read surface (this API's list and detail
        endpoints, the in-app feedback lists and CSV exports, search, reporting
        and the engagement signal it fed), and it stops counting toward the
        tenant's response rate. Nothing about the request or the response
        changes — this note exists so integrators do not treat a 200 here as
        proof the data is gone.

        Re-submitting feedback for the same attendance record afterwards
        RESTORES the withdrawn submission in place: `POST /shift_feedbacks`
        returns 201 with the SAME `id`, carrying the newly posted content and
        the ORIGINAL `submitted_at` — so the 24-hour edit/withdraw window runs
        from the first submission and does not restart.

        The shift is NOT put back on `GET /shift_feedbacks/eligible_shifts`, and
        it is not re-offered on any in-app "rate your shift" prompt. A withdrawal
        is a decision the app records, so re-submitting is a deliberate POST by
        the client that withdrew — not something the worker is prompted for
        again.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Shift feedback ID
      responses:
        '200':
          description: Shift feedback withdrawn successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Feedback deleted successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `deletion_not_allowed` (more than 24 hours since
            submission), `feedback_collection_disabled` (the tenant has turned
            Shift Feedback collection off), `app_not_enabled` /
            `app_access_denied` (the tenant has not enabled the app, or this
            user is outside its audience), or `insufficient_permissions`
            (the token lacks `write:shift_feedback`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `feedback_not_found` — no such feedback for the authenticated user
            in this business. A feedback belonging to ANOTHER user answers 404,
            not 403: the lookup is scoped to the caller's own submissions and
            never discloses that the id exists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_feedbacks/eligible_shifts":
    get:
      tags:
      - Shifts
      - Feedback
      summary: Get shifts eligible for feedback
      description: |
        Get a list of completed shifts that are eligible for feedback submission.
        Only returns shifts that:
        - Have been completed (user was checked in and out)
        - Don't already have feedback submitted — a WITHDRAWN submission still
          counts as submitted here, so a shift whose feedback was withdrawn is
          not re-offered
        - Belong to the authenticated user

        Returns the tenant's configured number of most recent eligible shifts
        (the `max_feedback_shifts` setting: 3 by default, 10 at most).
      responses:
        '200':
          description: Eligible shifts retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  eligible_shifts:
                    type: array
                    items:
                      type: object
                      properties:
                        attendance_record_id:
                          type: integer
                          example: 123
                        shift_id:
                          type: integer
                          example: 456
                        shift_name:
                          type: string
                          example: Morning Shift - Location A
                        shift_date:
                          type: string
                          format: date
                          example: '2025-10-09'
                        shift_start_time:
                          type: string
                          format: date-time
                          example: '2025-10-09T08:00:00Z'
                        check_in_time:
                          type: string
                          format: date-time
                          example: '2025-10-09T08:05:00Z'
                        check_out_time:
                          type: string
                          format: date-time
                          example: '2025-10-09T16:00:00Z'
                        hours_worked:
                          type: number
                          format: float
                          example: 7.92
                  count:
                    type: integer
                    description: Number of eligible shifts
                    example: 3
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/missing_punch_requests":
    get:
      tags:
      - Attendance
      summary: List the caller's missing-punch requests
      description: |
        The authenticated employee's own punch-correction requests, newest first.

        Deliberately self-scoped — this is the employee surface. The manager
        review queue is `GET /api/v1/attendance_records/requires_review`, which
        carries its own role gate.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
        description: "`pending` returns only requests still awaiting review — the
          set the caller can still cancel. Any other value is ignored rather than
          rejected, so new values can be added without breaking old clients.\n"
      responses:
        '200':
          description: Requests retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: 'An employee-submitted punch correction. Stored
                        as an AttendanceRecord with punch_source=''missing_punch_request''
                        and flagged for manager review — it is a REQUEST, not an approved
                        punch, until a manager acts on it.

                        '
                      properties:
                        id:
                          type: integer
                        status:
                          type: string
                          description: "`pending_review` until a manager acts; afterwards
                            the underlying attendance status.\n"
                        pending_review:
                          type: boolean
                        check_in_time:
                          type: string
                          format: date-time
                        check_out_time:
                          type: string
                          format: date-time
                        reason:
                          type: string
                          description: The employee's stated reason for the correction.
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        can_cancel:
                          type: boolean
                          description: 'Whether DELETE would succeed — true only while
                            no manager has acted. Use it to decide whether to show
                            a cancel control instead of discovering the rule from
                            a 422.

                            '
                        shift_id:
                          type: integer
                          description: Detailed responses only (create). The supporting
                            ad-hoc shift.
                        location_name:
                          type: string
                          nullable: true
                          description: Detailed responses only.
                        breaks:
                          type: array
                          description: 'Detailed responses only. Required break types
                            the employee did not submit appear with status `skipped`.

                            '
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              break_type_id:
                                type: integer
                              break_type_name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                                nullable: true
                              end_time:
                                type: string
                                format: date-time
                                nullable: true
                              duration_minutes:
                                type: integer
                                nullable: true
                              status:
                                type: string
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
    post:
      tags:
      - Attendance
      summary: Submit a missing-punch request
      description: |
        File a punch correction for a day the clock missed. Creates an
        AttendanceRecord flagged for manager review (it is NOT an approved
        punch), plus a supporting ad-hoc shift, and notifies the managers who
        have to review it.

        All rules are shared with the web form — reason bounds, maximum span,
        the retroactive window, and break-row validation. Call
        `GET /missing_punch_requests/window` first and build the picker from
        those bounds so the client cannot offer a date this endpoint refuses.

        Times are parsed server-side: an unparseable value is refused, never
        cast to null.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - check_in_time
              - check_out_time
              - reason
              properties:
                check_in_time:
                  type: string
                  format: date-time
                  description: Must be in the past and inside the allowed window.
                check_out_time:
                  type: string
                  format: date-time
                  description: 'Must be after check_in_time. May fall on the following
                    day — overnights are allowed — but the total span is capped (see
                    max_span_hours on the window endpoint).

                    '
                reason:
                  type: string
                  description: Why the punch is missing. Length bounds come from the
                    window endpoint.
                timesheet_id:
                  type: integer
                  description: 'Optional. Narrows the allowed window to that timesheet''s
                    pay period. An id that is not the caller''s own is ignored rather
                    than borrowing another employee''s period.

                    '
                breaks:
                  type: array
                  description: 'Optional break rows. Each must sit inside the punch
                    window and carry both times or neither. Required break types the
                    caller omits are recorded as skipped.

                    '
                  items:
                    type: object
                    properties:
                      break_type_id:
                        type: integer
                      start_time:
                        type: string
                        format: date-time
                      end_time:
                        type: string
                        format: date-time
      responses:
        '201':
          description: Request submitted and pending manager review
          content:
            application/json:
              schema:
                type: object
                properties:
                  missing_punch_request:
                    type: object
                    description: 'An employee-submitted punch correction. Stored as
                      an AttendanceRecord with punch_source=''missing_punch_request''
                      and flagged for manager review — it is a REQUEST, not an approved
                      punch, until a manager acts on it.

                      '
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        description: "`pending_review` until a manager acts; afterwards
                          the underlying attendance status.\n"
                      pending_review:
                        type: boolean
                      check_in_time:
                        type: string
                        format: date-time
                      check_out_time:
                        type: string
                        format: date-time
                      reason:
                        type: string
                        description: The employee's stated reason for the correction.
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      can_cancel:
                        type: boolean
                        description: 'Whether DELETE would succeed — true only while
                          no manager has acted. Use it to decide whether to show a
                          cancel control instead of discovering the rule from a 422.

                          '
                      shift_id:
                        type: integer
                        description: Detailed responses only (create). The supporting
                          ad-hoc shift.
                      location_name:
                        type: string
                        nullable: true
                        description: Detailed responses only.
                      breaks:
                        type: array
                        description: 'Detailed responses only. Required break types
                          the employee did not submit appear with status `skipped`.

                          '
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            break_type_id:
                              type: integer
                            break_type_name:
                              type: string
                            start_time:
                              type: string
                              format: date-time
                              nullable: true
                            end_time:
                              type: string
                              format: date-time
                              nullable: true
                            duration_minutes:
                              type: integer
                              nullable: true
                            status:
                              type: string
                  managers_notified:
                    type: boolean
                    description: 'Whether any manager was actually reached. FALSE
                      is a real outcome — a location supervised only by an admin can
                      resolve no manager recipient — so do not tell the employee their
                      request was sent to a manager unless this is true.

                      '
                  notification_status:
                    type: string
                    enum:
                    - notified
                    - no_recipients
                    - delivery_failed
                    description: 'Why `managers_notified` is what it is. Prefer this
                      over the boolean when wording the confirmation, because `no_recipients`
                      and `delivery_failed` need DIFFERENT copy: `no_recipients` means
                      no manager is assigned to the location (tell them to contact
                      HR), while `delivery_failed` means the request IS in their manager''s
                      review queue and only the alert was lost (do not send them to
                      HR). The request itself is created and pending review in all
                      three cases.

                      '
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
        '422':
          description: 'Validation failure. `error.code` is `validation_failed` for
            input the employee can fix (reason length, span, window, break rows) or
            `record_invalid` when the record itself was rejected. `error.message`
            is the sentence to show them.

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/missing_punch_requests/window":
    get:
      tags:
      - Attendance
      summary: Bounds and limits for building the missing-punch form
      description: |
        Everything a client needs to render the form so it cannot offer a value
        the submit endpoint refuses: the allowed datetime range, whether the
        window is open at all, the reason/span limits, and the required break
        rows. Read off the same object that validates the submit.
      security:
      - BearerAuth: []
      parameters:
      - name: timesheet_id
        in: query
        schema:
          type: integer
        description: Narrow the window to that timesheet's pay period.
      responses:
        '200':
          description: Window resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  window:
                    type: object
                    properties:
                      open:
                        type: boolean
                        description: 'FALSE when the pay period and the retroactive
                          window do not overlap — there is no date the submit endpoint
                          would accept. Withhold the form and show closed_reason.

                          '
                      min_time:
                        type: string
                        format: date-time
                        nullable: true
                        description: Earliest allowed clock-in. Null means no lower
                          bound.
                      max_time:
                        type: string
                        format: date-time
                        description: Latest allowed clock-in (never in the future).
                      check_out_max_time:
                        type: string
                        format: date-time
                        description: 'Ceiling for clock-OUT, deliberately later than
                          max_time so a shift starting on the period''s last day can
                          end the next morning.

                          '
                      scoped_to_timesheet:
                        type: boolean
                      timesheet_period:
                        type: string
                        nullable: true
                      lockout_days:
                        type: integer
                        description: The tenant's retroactive correction window, in
                          days.
                      range_sentence:
                        type: string
                        nullable: true
                        description: 'The allowed range in one sentence, shared with
                          the web form''s helper text and the server''s rejection
                          so no surface states a different rule. NULL when open is
                          false — a closed window''s minimum is later than its maximum,
                          so there is no coherent range to state; use closed_reason
                          instead.

                          '
                      closed_reason:
                        type: string
                        nullable: true
                        description: Why the window is closed. Null when open.
                      reason_min_length:
                        type: integer
                      reason_max_length:
                        type: integer
                      max_span_hours:
                        type: integer
                      required_break_types:
                        type: array
                        description: 'Required break types only — an optional break
                          the employee did not take needs no row.

                          '
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            duration_minutes:
                              type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
  "/missing_punch_requests/{id}":
    delete:
      tags:
      - Attendance
      summary: Withdraw a pending missing-punch request
      description: |
        Cancel a request no manager has acted on yet, removing the supporting
        ad-hoc shift with it. A request belonging to another employee, or one
        already reviewed, is refused — use `can_cancel` on the request to decide
        whether to show the control.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Request cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                  id:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
        '409':
          description: "`cancel_blocked` — other records (lone-worker check-ins or
            alerts, attendance notifications) still reference this request, so it
            can never be cancelled by retrying. Ask a manager to reject it instead.\n"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`not_cancellable` — already reviewed, not a missing-punch
            request, or not the caller's own.\n"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/break_types":
    get:
      tags:
      - Attendance
      summary: List break types
      description: |
        The tenant's break types, for a break picker. Without this, a client can
        only see breaks already planned on the current shift (the
        `break_records`/`break_type` embeds on the attendance record), so a
        tenant whose optional break types are not attached to shifts shows the
        employee an empty break list.

        The field set matches the `break_type` embed on the attendance record
        exactly, so one client-side model decodes both. Required types are
        included rather than filtered out — `is_required` is present precisely
        so the client can split required-and-auto-attached from
        optional-and-startable itself.

        Not paginated: a tenant's break-type count is naturally small and a
        picker needs the full set.
      responses:
        '200':
          description: The tenant's break types, ordered by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  break_types:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 5
                        name:
                          type: string
                          example: Meal Break
                        duration_minutes:
                          type: integer
                          example: 30
                        is_required:
                          type: boolean
                          example: true
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                        example: 4
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/attendance_records":
    get:
      tags:
      - Attendance
      summary: List attendance records
      description: |
        Returns a paginated list of attendance records with optional filters for user, shift, and status.
        Use this to retrieve historical attendance activity for reporting and audits.
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: user_id
        in: query
        schema:
          type: integer
      - name: shift_id
        in: query
        schema:
          type: integer
      - name: status
        in: query
        description: Filter by attendance status. Comma-separated values are accepted.
          These are the real AttendanceRecord statuses; `approved`/`rejected` were
          previously documented here but are review outcomes on a different column,
          so requesting them returned an empty list forever.
        schema:
          type: string
          enum:
          - pending
          - on_time
          - late
          - missed
          - completed
          - absence_reported
      responses:
        '200':
          description: List of attendance records
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        user_id:
                          type: integer
                        shift_id:
                          type: integer
                        business_id:
                          type: integer
                        check_in_time:
                          type: string
                          format: date-time
                          nullable: true
                        check_out_time:
                          type: string
                          format: date-time
                          nullable: true
                        status:
                          type: string
                          enum:
                          - pending
                          - on_time
                          - late
                          - missed
                          - completed
                          - absence_reported
                          - excused
                          - no_show
                        location_verified:
                          type: boolean
                          default: false
                        photo_verification_required:
                          type: boolean
                          default: false
                        photo_verification_failed_reason:
                          type: string
                          nullable: true
                        is_unknown_device:
                          type: boolean
                          default: false
                        requires_review:
                          type: boolean
                          default: false
                        review_reason:
                          type: string
                          nullable: true
                        reviewed_by_user_id:
                          type: integer
                          nullable: true
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                        excused_by_user_id:
                          type: integer
                          nullable: true
                        excused_at:
                          type: string
                          format: date-time
                          nullable: true
                        absence_report_id:
                          type: integer
                          nullable: true
                        notes:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        user:
                          "$ref": "#/components/schemas/User"
                        shift:
                          allOf:
                          - "$ref": "#/components/schemas/Shift"
                          nullable: true
                          description: 'The record''s shift. A SUBSET of the Shift
                            schema: id, name, start_time, end_time, formatted_date,
                            formatted_time — plus status, location and location_department
                            when `include=shift_details` is requested. `formatted_date`
                            ("Wednesday, Aug 05") and `formatted_time` ("01:30 PM
                            - 09:30 PM") are rendered in the SHIFT''s own timezone,
                            are byte-identical to what `GET /shifts` returns for the
                            same shift.id, and do not vary with the caller''s profile
                            timezone — clients should prefer them over formatting
                            `start_time` locally, which has no usable zone once an
                            SDK decodes it to a bare instant. `formatted_date` always
                            names the shift''s START day, so it is correct for a shift
                            crossing midnight.'
                        break_records:
                          type: array
                          items:
                            "$ref": "#/components/schemas/BreakRecord"
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
    post:
      tags:
      - Attendance
      summary: Create attendance record (check-in)
      description: |
        Creates a new attendance record representing a check-in event. If `shift_id` is omitted or null,
        the system creates an adhoc shift at the user's primary location and associates the record automatically.

        **Adhoc Clock-in**: When shift_id is null, the system will:
        1. Resolve the location — `location_id` when supplied, otherwise the
           user's primary assigned location, else their assigned active
           locations by name, else the business's active locations by name
        2. Create an adhoc shift with no predetermined end time
        3. Create a shift assignment for the user
        4. Create the attendance record with the new shift_id

        Supply `location_id` to let the employee choose where they are punching
        in; `GET /locations` returns exactly the set that is accepted. A
        `location_id` outside that set is REFUSED with 422 rather than being
        ignored — a chooser whose choice is silently discarded would land the
        punch at a site the employee did not pick.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              - check_in_time
              properties:
                user_id:
                  type: integer
                  description: ID of the user checking in
                shift_id:
                  type: integer
                  nullable: true
                  description: ID of the shift to check in for. If null, an adhoc
                    shift will be created automatically.
                check_in_time:
                  type: string
                  format: date-time
                  description: Time of check-in
                location_id:
                  type: integer
                  description: Adhoc clock-in only (ignored when shift_id is supplied
                    — a scheduled punch takes its shift's location). Must be one of
                    the caller's assigned ACTIVE locations for this business; anything
                    else is refused with 422. Omit to accept the resolved default
                    described above. Feed the picker from `GET /locations`.
                location:
                  type: string
                  description: Location description
                device_info:
                  type: string
                  description: Device information
                notes:
                  type: string
                  description: Additional notes
      responses:
        '201':
          description: Attendance record created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
        '422':
          description: |
            Validation failed. For adhoc clock-in, `errors[].field` =
            `location_id` means either the supplied location is not one of the
            caller's assigned active locations, or none was supplied and the
            user has no assignable location to fall back to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: |
            Refused. Either the caller may not create a record for the named
            employee, or — `error.code` = `kiosk_mode_enabled` — the business
            runs a shared Time Clock Kiosk and self-service punching from a
            personal device is turned off. The kiosk refusal carries
            `error.details.time_clock_kiosk_enabled: true` and applies only when
            the record is for the caller themselves; a manager recording a punch
            for a subordinate is unaffected. Not retryable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/check_out":
    post:
      tags:
      - Attendance
      summary: Check out from shift
      description: |
        Completes an attendance record by recording a check-out time and optional metadata such as notes
        and location. Returns the updated attendance record.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                check_out_time:
                  type: string
                  format: date-time
                location:
                  type: string
                notes:
                  type: string
      responses:
        '200':
          description: |
            Successfully checked out. Alongside `attendance_record`, the body
            carries an `undo` object describing the Undo Clock-Out window that
            just opened — render the countdown from `undo_seconds_remaining`
            (measured against the server clock, so a device with clock drift
            still counts down correctly) and POST
            `/attendance_records/{id}/undo_clock_out` while it is above zero.
            `GET /attendance_records/status` carries the same `undo` object, so
            a client that relaunches inside the window can rebuild the card.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  undo:
                    "$ref": "#/components/schemas/UndoClockOutWindow"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so clocking out from a personal device is turned off.
            Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable — the
            employee must clock out at the kiosk.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/undo_clock_out":
    post:
      tags:
      - Attendance
      summary: Undo a clock-out
      description: |
        Reverses the CALLER'S OWN clock-out within the 15-minute undo window,
        reopening the punch so they can keep working. Self-service only: a
        manager correcting someone else's punch uses
        `PATCH /attendance_records/{id}/adjust`, which is audited as a manager
        edit. Refused once the caller has clocked in again.

        Send an `Idempotency-Key` header to make a network retry safe — a replay
        returns the original response with `X-Idempotency-Cached: true` rather
        than failing as an expired window.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: |
            Clock-out undone; the caller is clocked back in. `undo` comes back
            with `can_undo_clock_out: false` because the punch is open again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  undo:
                    "$ref": "#/components/schemas/UndoClockOutWindow"
                  message:
                    type: string
        '403':
          description: |
            `error.code` = `not_your_record` — the record belongs to someone
            else. Or `kiosk_mode_enabled` — the business runs a shared Time Clock
            Kiosk, so reversing a punch from a personal device is turned off.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            `error.code` = `undo_window_expired` — the window has passed, the
            record is not a completed clock-out, or shift feedback was already
            submitted; `error.details` carries the same `undo` fields so the
            client can re-render the card. Or `already_clocked_in` — the caller
            has since started another punch (`error.details.open_attendance_record_id`).
            Or `undo_clock_out_failed` — the reversal was refused on save.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}":
    get:
      tags:
      - Attendance
      summary: Get attendance record details
      description: Retrieve a single attendance record by ID, including times and
        status.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Attendance record details
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
    put:
      tags:
      - Attendance
      summary: Update attendance record
      description: Update an existing attendance record's times, status, or notes.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                check_in_time:
                  type: string
                  format: date-time
                check_out_time:
                  type: string
                  format: date-time
                status:
                  type: string
                  enum:
                  - pending
                  - on_time
                  - late
                  - missed
                  - completed
                  - absence_reported
                  - excused
                  - no_show
                notes:
                  type: string
      responses:
        '200':
          description: Attendance record updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
    delete:
      tags:
      - Attendance
      summary: Delete attendance record
      description: Permanently deletes an attendance record. Admin-only in most environments.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Attendance record deleted successfully
  "/attendance_records/{id}/mark_for_review":
    post:
      tags:
      - Attendance
      summary: Mark attendance record for review
      description: Flags an attendance record for manager review due to potential
        issues.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Record marked for review
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/approve":
    post:
      tags:
      - Attendance
      summary: Approve attendance record
      description: Marks an attendance record as approved following review.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Record approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/reject":
    post:
      tags:
      - Attendance
      summary: Reject attendance record
      description: Marks an attendance record as rejected following review, preserving
        audit trail.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Record rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/adjust":
    patch:
      tags:
      - Attendance
      summary: Adjust attendance record times
      description: Adjusts check-in/check-out times, break duration, and notes for
        the record.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                attendance_record:
                  type: object
                  properties:
                    check_in_time:
                      type: string
                      format: date-time
                    check_out_time:
                      type: string
                      format: date-time
                    break_duration:
                      type: integer
                    notes:
                      type: string
                    adjustment_reason:
                      type: string
      responses:
        '200':
          description: Record adjusted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/check_in":
    post:
      tags:
      - Attendance
      summary: Check in to shift
      description: Records a check-in event on an existing attendance record with
        optional verification.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                latitude:
                  type: number
                  format: float
                  description: GPS latitude for location verification
                longitude:
                  type: number
                  format: float
                  description: GPS longitude for location verification
                device_fingerprint:
                  type: string
                  description: Device identification for verification
                photo:
                  type: string
                  format: binary
                  description: Photo for verification (if required)
      responses:
        '200':
          description: Successfully checked in
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so clocking in from a personal device is turned off.
            Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable — the
            employee must clock in at the kiosk.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Check-in validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/start_break":
    post:
      tags:
      - Attendance
      summary: Start a break
      description: Starts a break for the current attendance session, optionally setting
        a break type.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                break_type_id:
                  type: integer
                  description: Type of break (optional, uses default if not provided)
      responses:
        '200':
          description: Break started successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so breaks are started at the kiosk, not from a personal
            device. Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Break start validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/end_break":
    post:
      tags:
      - Attendance
      summary: End current break
      description: Ends the active break for the attendance record and updates totals.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Break ended successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so breaks are ended at the kiosk, not from a personal
            device. Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: No active break to end
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/dashboard":
    get:
      tags:
      - Attendance
      summary: Get attendance dashboard statistics
      description: Provides summarized counts and KPIs for the attendance dashboard.
      responses:
        '200':
          description: Dashboard statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_records:
                    type: integer
                  pending_review:
                    type: integer
                  missing_check_out:
                    type: integer
                  today_records:
                    type: integer
  "/attendance_records/analytics":
    get:
      tags:
      - Attendance
      summary: Get attendance analytics
      description: Returns aggregate analytics for attendance across dimensions such
        as status and location.
      responses:
        '200':
          description: Attendance analytics data
          content:
            application/json:
              schema:
                type: object
                properties:
                  by_status:
                    type: object
                    additionalProperties:
                      type: integer
                  by_department:
                    type: object
                    additionalProperties:
                      type: integer
                  by_location:
                    type: object
                    additionalProperties:
                      type: integer
                  average_duration:
                    type: number
                    format: float
  "/attendance_records/history":
    get:
      tags:
      - Attendance
      summary: Get attendance history
      description: Returns a recent history list of attendance records with a configurable
        limit.
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          default: 100
      responses:
        '200':
          description: Attendance history
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    user_id:
                      type: integer
                    shift_id:
                      type: integer
                    business_id:
                      type: integer
                    check_in_time:
                      type: string
                      format: date-time
                      nullable: true
                    check_out_time:
                      type: string
                      format: date-time
                      nullable: true
                    status:
                      type: string
                      enum:
                      - pending
                      - on_time
                      - late
                      - missed
                      - completed
                      - absence_reported
                      - excused
                      - no_show
                    location_verified:
                      type: boolean
                      default: false
                    photo_verification_required:
                      type: boolean
                      default: false
                    photo_verification_failed_reason:
                      type: string
                      nullable: true
                    is_unknown_device:
                      type: boolean
                      default: false
                    requires_review:
                      type: boolean
                      default: false
                    review_reason:
                      type: string
                      nullable: true
                    reviewed_by_user_id:
                      type: integer
                      nullable: true
                    reviewed_at:
                      type: string
                      format: date-time
                      nullable: true
                    excused_by_user_id:
                      type: integer
                      nullable: true
                    excused_at:
                      type: string
                      format: date-time
                      nullable: true
                    absence_report_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    created_at:
                      type: string
                      format: date-time
                    updated_at:
                      type: string
                      format: date-time
                    user:
                      "$ref": "#/components/schemas/User"
                    shift:
                      allOf:
                      - "$ref": "#/components/schemas/Shift"
                      nullable: true
                      description: 'The record''s shift. A SUBSET of the Shift schema:
                        id, name, start_time, end_time, formatted_date, formatted_time
                        — plus status, location and location_department when `include=shift_details`
                        is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                        ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                        are byte-identical to what `GET /shifts` returns for the same
                        shift.id, and do not vary with the caller''s profile timezone
                        — clients should prefer them over formatting `start_time`
                        locally, which has no usable zone once an SDK decodes it to
                        a bare instant. `formatted_date` always names the shift''s
                        START day, so it is correct for a shift crossing midnight.'
                    break_records:
                      type: array
                      items:
                        "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/missing_check_out":
    get:
      tags:
      - Attendance
      summary: Get records missing check-out
      description: Lists attendance records that are missing a check-out and may require
        follow-up.
      responses:
        '200':
          description: Records missing check-out
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    user_id:
                      type: integer
                    shift_id:
                      type: integer
                    business_id:
                      type: integer
                    check_in_time:
                      type: string
                      format: date-time
                      nullable: true
                    check_out_time:
                      type: string
                      format: date-time
                      nullable: true
                    status:
                      type: string
                      enum:
                      - pending
                      - on_time
                      - late
                      - missed
                      - completed
                      - absence_reported
                      - excused
                      - no_show
                    location_verified:
                      type: boolean
                      default: false
                    photo_verification_required:
                      type: boolean
                      default: false
                    photo_verification_failed_reason:
                      type: string
                      nullable: true
                    is_unknown_device:
                      type: boolean
                      default: false
                    requires_review:
                      type: boolean
                      default: false
                    review_reason:
                      type: string
                      nullable: true
                    reviewed_by_user_id:
                      type: integer
                      nullable: true
                    reviewed_at:
                      type: string
                      format: date-time
                      nullable: true
                    excused_by_user_id:
                      type: integer
                      nullable: true
                    excused_at:
                      type: string
                      format: date-time
                      nullable: true
                    absence_report_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    created_at:
                      type: string
                      format: date-time
                    updated_at:
                      type: string
                      format: date-time
                    user:
                      "$ref": "#/components/schemas/User"
                    shift:
                      allOf:
                      - "$ref": "#/components/schemas/Shift"
                      nullable: true
                      description: 'The record''s shift. A SUBSET of the Shift schema:
                        id, name, start_time, end_time, formatted_date, formatted_time
                        — plus status, location and location_department when `include=shift_details`
                        is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                        ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                        are byte-identical to what `GET /shifts` returns for the same
                        shift.id, and do not vary with the caller''s profile timezone
                        — clients should prefer them over formatting `start_time`
                        locally, which has no usable zone once an SDK decodes it to
                        a bare instant. `formatted_date` always names the shift''s
                        START day, so it is correct for a shift crossing midnight.'
                    break_records:
                      type: array
                      items:
                        "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/requires_review":
    get:
      tags:
      - Attendance
      summary: Get records requiring review
      description: Lists attendance records flagged for review by automated or manual
        checks.
      responses:
        '200':
          description: Records requiring review
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    user_id:
                      type: integer
                    shift_id:
                      type: integer
                    business_id:
                      type: integer
                    check_in_time:
                      type: string
                      format: date-time
                      nullable: true
                    check_out_time:
                      type: string
                      format: date-time
                      nullable: true
                    status:
                      type: string
                      enum:
                      - pending
                      - on_time
                      - late
                      - missed
                      - completed
                      - absence_reported
                      - excused
                      - no_show
                    location_verified:
                      type: boolean
                      default: false
                    photo_verification_required:
                      type: boolean
                      default: false
                    photo_verification_failed_reason:
                      type: string
                      nullable: true
                    is_unknown_device:
                      type: boolean
                      default: false
                    requires_review:
                      type: boolean
                      default: false
                    review_reason:
                      type: string
                      nullable: true
                    reviewed_by_user_id:
                      type: integer
                      nullable: true
                    reviewed_at:
                      type: string
                      format: date-time
                      nullable: true
                    excused_by_user_id:
                      type: integer
                      nullable: true
                    excused_at:
                      type: string
                      format: date-time
                      nullable: true
                    absence_report_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    created_at:
                      type: string
                      format: date-time
                    updated_at:
                      type: string
                      format: date-time
                    user:
                      "$ref": "#/components/schemas/User"
                    shift:
                      allOf:
                      - "$ref": "#/components/schemas/Shift"
                      nullable: true
                      description: 'The record''s shift. A SUBSET of the Shift schema:
                        id, name, start_time, end_time, formatted_date, formatted_time
                        — plus status, location and location_department when `include=shift_details`
                        is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                        ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                        are byte-identical to what `GET /shifts` returns for the same
                        shift.id, and do not vary with the caller''s profile timezone
                        — clients should prefer them over formatting `start_time`
                        locally, which has no usable zone once an SDK decodes it to
                        a bare instant. `formatted_date` always names the shift''s
                        START day, so it is correct for a shift crossing midnight.'
                    break_records:
                      type: array
                      items:
                        "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/auto_check_out":
    post:
      tags:
      - Attendance
      summary: Auto check-out stale records
      description: Automatically check out records that have been open for more than
        24 hours
      responses:
        '200':
          description: Auto check-out completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  results:
                    type: object
                    properties:
                      success:
                        type: array
                        items:
                          type: integer
                      failed:
                        type: array
                        items:
                          type: object
  "/absence_reasons":
    get:
      tags:
      - Attendance
      summary: The tenant's absence reason codes
      description: |
        The configured reason list behind the "Report Delay or Absence" form —
        the same set, in the same order, that the web picker is built from. Read
        this instead of shipping a built-in list: the six defaults are only a
        SEED, and a tenant may rename, retire or add codes at any time from
        /admin/absence_reason_codes.

        Not paginated — the whole set is one select's worth of options.

        Read-only. Reason codes are admin configuration; there is no API to
        create or edit one.
      security:
      - BearerAuth: []
      parameters:
      - name: include_inactive
        in: query
        schema:
          type: boolean
          default: false
        description: |
          Include RETIRED codes. Default false, which is what a
          new-report picker wants — nothing new may be filed under a retired
          reason.

          Pass true only to NAME a reason on a report that already exists: a
          code is retired with a flag rather than deleted, so a report filed
          before the retirement still points at one. Every row carries
          `active`, so a client asking for the full set can still keep retired
          codes out of the picker.
      responses:
        '200':
          description: Reason codes for the caller's business
          content:
            application/json:
              schema:
                type: object
                properties:
                  absence_reasons:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AbsenceReason"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      include_inactive:
                        type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: 'Time & Attendance is not enabled for the business or is not
            accessible to this user, or the token lacks `read:attendance` / `read:own_attendance`.

            '
  "/shift_marketplace/listings":
    get:
      tags:
      - Shift Marketplace
      summary: List Shift Marketplace Listings
      description: |
        Returns a list of shift marketplace listings with filtering by listing type, status, price, and location.
        Supports pickup-only, trade-only, and both listing types.
      security:
      - BearerAuth: []
      parameters:
      - name: listing_type
        in: query
        description: Filter by listing type
        schema:
          type: string
          enum:
          - pickup
          - trade_only
          - both
      - name: status
        in: query
        description: Filter by listing status
        schema:
          type: string
          enum:
          - open
          - filled
          - closed
          - cancelled
      - name: min_price
        in: query
        schema:
          type: number
      - name: max_price
        in: query
        schema:
          type: number
      - name: location_id
        in: query
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: List of marketplace listings
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: A marketplace listing for shifts (pickup, trade_only,
                        or both)
                      properties:
                        id:
                          type: integer
                          example: 1
                        listing_type:
                          type: string
                          enum:
                          - pickup
                          - trade_only
                          - both
                          example: pickup
                          description: Type of listing - pickup (direct claim), trade_only
                            (requires application), or both
                        status:
                          type: string
                          enum:
                          - open
                          - filled
                          - closed
                          - cancelled
                          example: open
                        price:
                          type: number
                          nullable: true
                          example: 0.01
                        currency:
                          type: string
                          example: USD
                        notes:
                          type: string
                          nullable: true
                          example: Need someone to cover this shift
                        urgent:
                          type: boolean
                          description: Whether this listing is marked as urgent
                          example: false
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        claimed_at:
                          type: string
                          format: date-time
                          nullable: true
                        shift:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            start_time:
                              type: string
                              format: date-time
                            end_time:
                              type: string
                              format: date-time
                            date:
                              type: string
                              format: date
                            formatted_date:
                              type: string
                            formatted_time:
                              type: string
                            location:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                        created_by:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            email:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                fallback to initials-based avatar
                            avatar_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        claimed_by:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            email:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                fallback to initials-based avatar
                            avatar_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        can_claim:
                          type: boolean
                          description: Whether current user can claim this listing
                        accepts_applications:
                          type: boolean
                          description: Whether this listing accepts applications (trade_only
                            or both)
                        is_trade_only:
                          type: boolean
                          description: Whether this is a trade-only listing
                        has_applied:
                          type: boolean
                          description: Whether current user has applied to this listing
                        application_status:
                          type: string
                          nullable: true
                          enum:
                          - pending
                          - accepted
                          - rejected
                          - withdrawn
                          description: Current user's application status (if applied)
                        application_id:
                          type: integer
                          nullable: true
                          description: Current user's application ID (if applied)
                        is_owner:
                          type: boolean
                          description: Whether current user owns this listing
                        can_edit:
                          type: boolean
                        can_delete:
                          type: boolean
                        applications:
                          type: array
                          description: Owner-only application summaries for this listing
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              status:
                                type: string
                                enum:
                                - pending
                                - accepted
                                - rejected
                                - withdrawn
                              notes:
                                type: string
                                nullable: true
                              created_at:
                                type: string
                                format: date-time
                              offered_shift:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                                  start_time:
                                    type: string
                                    format: date-time
                                  end_time:
                                    type: string
                                    format: date-time
                                  formatted_date:
                                    type: string
                                  formatted_time:
                                    type: string
                                  location:
                                    type: object
                                    nullable: true
                                    properties:
                                      id:
                                        type: integer
                                      name:
                                        type: string
                              applicant:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                                  avatar_url:
                                    type: string
                                    nullable: true
                                  avatar_thumbnail_url:
                                    type: string
                                    nullable: true
                        applications_count:
                          type: integer
                          description: Total applications (owner only)
                        pending_applications_count:
                          type: integer
                          description: Pending applications count (owner only)
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shift Marketplace
      summary: Create Marketplace Listing
      description: Create a new shift marketplace listing (pickup, trade_only, or
        both)
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - listing
              properties:
                listing:
                  type: object
                  required:
                  - shift_id
                  - listing_type
                  properties:
                    shift_id:
                      type: integer
                      example: 123
                    listing_type:
                      type: string
                      enum:
                      - pickup
                      - trade_only
                      - both
                      example: pickup
                      description: Type of listing - pickup (direct claim), trade_only
                        (requires application), or both
                    price:
                      type: number
                      example: 0.01
                      description: Optional price/incentive for pickup
                    currency:
                      type: string
                      example: USD
                      default: USD
                    notes:
                      type: string
                      example: Need someone to cover this shift
                      description: Optional notes about the listing
      responses:
        '200':
          description: Listing created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shift_marketplace/listings/{id}":
    get:
      tags:
      - Shift Marketplace
      summary: Get Listing Details
      description: |
        Returns detailed information about a specific marketplace listing including:
        - Basic listing information (type, status, price)
        - Associated shift details
        - Current user's application status (has_applied, application_status, application_id)
        - Applications list if user is the listing owner
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Listing details
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Shift Marketplace
      summary: Update Listing
      description: Update a marketplace listing (owner only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                listing:
                  type: object
                  properties:
                    listing_type:
                      type: string
                      enum:
                      - pickup
                      - trade_only
                      - both
                    price:
                      type: number
                    notes:
                      type: string
      responses:
        '200':
          description: Listing updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Shift Marketplace
      summary: Delete Listing
      description: Delete a marketplace listing (owner only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Listing deleted
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shift_marketplace/listings/{id}/claim":
    post:
      tags:
      - Shift Marketplace
      summary: Claim Listing
      description: |
        Claim a pickup or both listing. Trade-only listings cannot be claimed directly.
        Use the applications endpoint instead for trade-only listings.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Listing claimed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '422':
          description: Cannot claim listing (trade-only, conflicts, or requirements
            not met)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications":
    get:
      tags:
      - Shift Marketplace
      summary: List User's Applications
      description: Returns applications submitted by the current user for trade-only
        or both listings
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Filter by application status
        schema:
          type: string
          enum:
          - pending
          - accepted
          - rejected
          - withdrawn
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: List of applications
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: An application to a trade listing
                      properties:
                        id:
                          type: integer
                          example: 1
                        status:
                          type: string
                          enum:
                          - pending
                          - accepted
                          - rejected
                          - withdrawn
                          example: pending
                        notes:
                          type: string
                          nullable: true
                          example: I can work this shift
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        listing:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            listing_type:
                              type: string
                            status:
                              type: string
                            shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            created_by:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                email:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                  description: Full-size profile photo URL (200x200)
                                    or fallback to initials-based avatar
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                                  description: Thumbnail profile photo URL (40x40)
                                    optimized for list views
                        applicant:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            email:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                fallback to initials-based avatar
                            avatar_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        is_applicant:
                          type: boolean
                          description: Whether current user is the applicant
                        is_listing_owner:
                          type: boolean
                          description: Whether current user owns the listing
                        can_accept:
                          type: boolean
                          description: Whether current user can accept this application
                        can_reject:
                          type: boolean
                          description: Whether current user can reject this application
                        can_withdraw:
                          type: boolean
                          description: Whether current user can withdraw this application
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shift Marketplace
      summary: Apply to Listing
      description: Submit an application to a trade-only or both listing
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - application
              properties:
                application:
                  type: object
                  required:
                  - listing_id
                  properties:
                    listing_id:
                      type: integer
                      example: 123
                    notes:
                      type: string
                      example: I can work this shift and have similar experience
      responses:
        '200':
          description: Application submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '422':
          description: Cannot apply (already applied, listing closed, or wrong type)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications/{id}":
    get:
      tags:
      - Shift Marketplace
      summary: Get Application Details
      description: View details of a specific application (applicant or listing owner
        only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application details
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shift_marketplace/applications/{id}/accept":
    post:
      tags:
      - Shift Marketplace
      summary: Accept Application
      description: |
        Accept a pending application (listing owner only).
        This transfers the shift to the applicant and closes the listing.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application accepted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot accept (not pending or applicant has conflict)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications/{id}/reject":
    post:
      tags:
      - Shift Marketplace
      summary: Reject Application
      description: Reject a pending application (listing owner only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot reject (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications/{id}/withdraw":
    post:
      tags:
      - Shift Marketplace
      summary: Withdraw Application
      description: Withdraw a pending application (applicant only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot withdraw (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/direct_offers":
    get:
      tags:
      - Shift Marketplace
      summary: List Direct Offers
      description: |
        Returns direct shift offers (peer-to-peer offers) with filtering options.

        Direct offers are one-to-one shift offers where a user offers their shift
        to a specific colleague, unlike marketplace listings which are public.

        **Authorization**: Users can only see offers they sent or received.
        **Business Scoping**: Results automatically scoped to current business.
      parameters:
      - name: filter_type
        in: query
        description: Filter offers by type (received/sent)
        schema:
          type: string
          enum:
          - received
          - sent
      - name: status
        in: query
        description: Filter by offer status
        schema:
          type: string
          enum:
          - pending
          - accepted
          - declined
          - expired
          - cancelled
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      security:
      - BearerAuth: []
      responses:
        '200':
          description: List of direct offers
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: A direct peer-to-peer shift offer
                      properties:
                        id:
                          type: integer
                          example: 1
                        status:
                          type: string
                          enum:
                          - pending
                          - accepted
                          - declined
                          - expired
                          - cancelled
                          example: pending
                        notes:
                          type: string
                          nullable: true
                        expires_at:
                          type: string
                          format: date-time
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        shift:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            start_time:
                              type: string
                              format: date-time
                            end_time:
                              type: string
                              format: date-time
                            formatted_date:
                              type: string
                            formatted_time:
                              type: string
                            location:
                              type: string
                              nullable: true
                        from_user:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            email:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                fallback to initials-based avatar
                            avatar_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        to_user:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            email:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                fallback to initials-based avatar
                            avatar_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                        is_sender:
                          type: boolean
                          description: Whether current user sent this offer
                        is_recipient:
                          type: boolean
                          description: Whether current user received this offer
                        can_accept:
                          type: boolean
                          description: Whether current user can accept this offer
                        can_decline:
                          type: boolean
                          description: Whether current user can decline this offer
                        can_cancel:
                          type: boolean
                          description: Whether current user can cancel this offer
                            (sender only, pending offers)
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shift Marketplace
      summary: Create Direct Offer
      description: Send a direct shift offer to a specific colleague
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - direct_offer
              properties:
                direct_offer:
                  type: object
                  required:
                  - shift_id
                  - to_user_id
                  properties:
                    shift_id:
                      type: integer
                      example: 123
                    to_user_id:
                      type: integer
                      example: 456
                    expires_at:
                      type: string
                      format: date-time
                      example: '2025-11-28T10:00:00Z'
                    notes:
                      type: string
                      example: Can you cover my shift?
      responses:
        '200':
          description: Direct offer created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  direct_offer:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shift_marketplace/direct_offers/{id}":
    get:
      tags:
      - Shift Marketplace
      summary: Get Direct Offer Details
      description: View details of a specific direct offer
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Direct offer details
          content:
            application/json:
              schema:
                type: object
                properties:
                  direct_offer:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shift_marketplace/direct_offers/{id}/accept":
    post:
      tags:
      - Shift Marketplace
      summary: Accept Direct Offer
      description: |
        Accept a direct offer (recipient only).
        Transfers the shift to the recipient and updates status.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Offer accepted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot accept (expired or already processed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/direct_offers/{id}/decline":
    post:
      tags:
      - Shift Marketplace
      summary: Decline Direct Offer
      description: Decline a direct offer (recipient only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Offer declined successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot decline (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/direct_offers/{id}/cancel":
    post:
      tags:
      - Shift Marketplace
      summary: Cancel Direct Offer
      description: |
        Cancel a direct offer that you sent (sender only).
        This withdraws the offer before the recipient responds.
        Only pending offers can be cancelled.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Offer cancelled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '403':
          description: Forbidden - can only cancel offers you sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Cannot cancel (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets":
    get:
      tags:
      - Timesheets
      summary: List user's timesheets
      description: |
        Get a paginated list of timesheets for the authenticated user.
        Supports filtering by status and date ranges.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: Filter by timesheet status
        schema:
          type: string
          enum:
          - pending
          - submitted
          - regional_pending
          - approved
          - rejected
          example: pending
      - name: start_date
        in: query
        description: Filter timesheets starting from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter timesheets ending before this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      responses:
        '200':
          description: List of timesheets
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheets:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        start_date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        end_date:
                          type: string
                          format: date
                          example: '2024-01-21'
                        status:
                          type: string
                          enum:
                          - pending
                          - submitted
                          - regional_pending
                          - approved
                          - rejected
                          example: pending
                        total_hours:
                          type: number
                          format: float
                          example: 40.0
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 0.0
                        submission_date:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-22T09:00:00Z'
                        approval_date:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-22T14:30:00Z'
                        approved_by:
                          type: string
                          nullable: true
                          example: John Manager
                        regional_approved:
                          type: boolean
                          example: false
                        regional_approved_by:
                          type: string
                          nullable: true
                          example: Jane Regional
                        regional_approved_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-22T16:00:00Z'
                        editable:
                          type: boolean
                          description: Whether the timesheet can be edited
                          example: true
                        submittable:
                          type: boolean
                          description: Whether the timesheet can be submitted
                          example: true
                        entries_count:
                          type: integer
                          description: Number of entries in this timesheet
                          example: 5
                        missing_punches_count:
                          type: integer
                          description: Number of entries with missing punches
                          example: 0
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-21T18:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Timesheets & Payroll feature not enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/current":
    get:
      tags:
      - Timesheets
      summary: Get current pay period timesheet
      description: |
        Get the timesheet for the current pay period. If no timesheet exists,
        it will be automatically generated based on attendance records and shifts.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current timesheet
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration or timesheet generation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}":
    get:
      tags:
      - Timesheets
      summary: Get timesheet details
      description: |
        Get detailed information about a specific timesheet including all entries,
        summary statistics, and approval status.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Timesheet details
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Timesheet not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    put:
      tags:
      - Timesheets
      summary: Update timesheet entries
      description: |
        Update timesheet entries with new times or notes. Only editable timesheets
        (pending or rejected status) can be updated.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                timesheet_entries:
                  type: object
                  description: Map of entry IDs to update parameters
                  additionalProperties:
                    type: object
                    required:
                    - edit_reason
                    properties:
                      start_time:
                        type: string
                        format: time
                        example: '09:00:00'
                      end_time:
                        type: string
                        format: time
                        example: '17:00:00'
                      edit_reason:
                        type: string
                        description: Required whenever a punch actually changes —
                          the reason is stored on the entry and in its edit history.
                          An entry whose times do not move is a no-op and needs none;
                          a changed entry sent without one is refused (422 edit_reason_required)
                          and no entry in the request is written.
                        example: Corrected clock-in time
                timesheet:
                  type: object
                  properties:
                    notes:
                      type: string
                      description: The employee's own note on the timesheet. This
                        endpoint is scoped to the caller's own timesheets, so manager_notes
                        — the approver's field, shown back to the employee as the
                        rejection reason — is deliberately not writable here.
                      example: Updated per employee request
      responses:
        '200':
          description: Timesheet updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Update failed, timesheet not editable, or a changed entry was
            sent without an edit_reason (edit_reason_required — the error details
            name every offending entry id, and nothing in the request is written).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/submit":
    post:
      tags:
      - Timesheets
      summary: Submit timesheet for approval
      description: |
        Submit a timesheet for manager approval. The timesheet must be in pending
        or rejected status and have at least one entry.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Timesheet submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                  submission:
                    type: object
                    properties:
                      resubmitted:
                        type: boolean
                        description: Whether this was a resubmission after rejection
                      accuracy_score:
                        type: number
                        format: float
                        description: Calculated accuracy score (if available)
                        example: 95.5
                      score_error:
                        type: string
                        description: Error message if score calculation failed
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Timesheet cannot be submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/entries":
    get:
      tags:
      - Timesheets
      summary: Get timesheet entries
      description: |
        Get all entries for a specific timesheet with detailed information
        including associated shifts and attendance records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Timesheet entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        start_time:
                          type: string
                          format: time
                          nullable: true
                          example: '09:00:00'
                        end_time:
                          type: string
                          format: time
                          nullable: true
                          example: '17:00:00'
                        hours:
                          type: number
                          format: float
                          nullable: true
                          example: 8.0
                        edited:
                          type: boolean
                          description: Whether this entry has been manually edited
                          example: false
                        edit_notes:
                          type: string
                          nullable: true
                          description: Notes about edits made to this entry
                          example: Corrected clock-in time
                        missing_punch:
                          type: boolean
                          description: Whether this entry is missing clock-in or clock-out
                          example: false
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          example: pending
                        attendance_record_id:
                          type: integer
                          nullable: true
                          description: Associated attendance record ID
                          example: 789
                        shift_id:
                          type: integer
                          nullable: true
                          description: Associated shift ID
                          example: 101
                        shift:
                          type: object
                          nullable: true
                          description: Associated shift information
                          properties:
                            id:
                              type: integer
                              example: 101
                            title:
                              type: string
                              example: Morning Shift
                            location:
                              type: string
                              nullable: true
                              example: Main Office
                            is_ad_hoc:
                              type: boolean
                              example: false
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T09:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T17:00:00Z'
                  timesheet_summary:
                    type: object
                    properties:
                      total_hours:
                        type: number
                        format: float
                        example: 40.0
                      regular_hours:
                        type: number
                        format: float
                        example: 40.0
                      overtime_hours:
                        type: number
                        format: float
                        example: 0.0
                      total_entries:
                        type: integer
                        example: 5
                      missing_punches:
                        type: integer
                        example: 0
                      edited_entries:
                        type: integer
                        example: 1
                      days_with_entries:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Timesheet not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheet_entries":
    get:
      tags:
      - Timesheet Entries
      summary: List timesheet entries
      description: |
        Get a paginated list of timesheet entries across all user's timesheets
        or for a specific timesheet. Supports various filters.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: timesheet_id
        in: query
        description: Filter entries for specific timesheet
        schema:
          type: integer
          example: 123
      - name: start_date
        in: query
        description: Filter entries from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter entries to this date
        schema:
          type: string
          format: date
          example: '2024-01-31'
      - name: status
        in: query
        description: Filter by entry status
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
      - name: edited
        in: query
        description: Filter by edited status
        schema:
          type: boolean
          example: true
      - name: missing_punch
        in: query
        description: Filter by missing punch status
        schema:
          type: boolean
          example: false
      responses:
        '200':
          description: List of timesheet entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        start_time:
                          type: string
                          format: time
                          nullable: true
                          example: '09:00:00'
                        end_time:
                          type: string
                          format: time
                          nullable: true
                          example: '17:00:00'
                        hours:
                          type: number
                          format: float
                          nullable: true
                          example: 8.0
                        edited:
                          type: boolean
                          description: Whether this entry has been manually edited
                          example: false
                        edit_notes:
                          type: string
                          nullable: true
                          description: Notes about edits made to this entry
                          example: Corrected clock-in time
                        missing_punch:
                          type: boolean
                          description: Whether this entry is missing clock-in or clock-out
                          example: false
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          example: pending
                        attendance_record_id:
                          type: integer
                          nullable: true
                          description: Associated attendance record ID
                          example: 789
                        shift_id:
                          type: integer
                          nullable: true
                          description: Associated shift ID
                          example: 101
                        shift:
                          type: object
                          nullable: true
                          description: Associated shift information
                          properties:
                            id:
                              type: integer
                              example: 101
                            title:
                              type: string
                              example: Morning Shift
                            location:
                              type: string
                              nullable: true
                              example: Main Office
                            is_ad_hoc:
                              type: boolean
                              example: false
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T09:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T17:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Timesheet Entries
      summary: Create manual timesheet entry
      description: |
        Create a manual timesheet entry for times not captured through
        attendance records. The timesheet must be editable.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - timesheet_entry
              properties:
                timesheet_entry:
                  type: object
                  required:
                  - timesheet_id
                  - date
                  - start_time
                  - end_time
                  properties:
                    timesheet_id:
                      type: integer
                      example: 123
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      description: Hours worked (calculated if not provided)
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Manual entry for missed punch
      responses:
        '201':
          description: Timesheet entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  entry:
                    allOf:
                    - "$ref": "#/components/schemas/TimesheetEntry"
                    - type: object
                      properties:
                        timesheet:
                          type: object
                          description: Associated timesheet information
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              example: pending
                        edit_history:
                          type: array
                          description: History of edits made to this entry
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: start_time
                              original_value:
                                type: string
                                example: '08:45:00'
                              new_value:
                                type: string
                                example: '09:00:00'
                              reason:
                                type: string
                                example: Employee correction
                              edited_by:
                                type: string
                                example: John Employee
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                        attendance_record:
                          type: object
                          nullable: true
                          description: Associated attendance record details
                          properties:
                            id:
                              type: integer
                              example: 789
                            check_in_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T09:00:00Z'
                            check_out_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T17:00:00Z'
                            status:
                              type: string
                              example: completed
                            requires_review:
                              type: boolean
                              example: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation failed or timesheet not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheet_entries/{id}":
    get:
      tags:
      - Timesheet Entries
      summary: Get timesheet entry details
      description: |
        Get detailed information about a specific timesheet entry including
        edit history and associated records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet entry ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Timesheet entry details
          content:
            application/json:
              schema:
                type: object
                properties:
                  entry:
                    allOf:
                    - "$ref": "#/components/schemas/TimesheetEntry"
                    - type: object
                      properties:
                        timesheet:
                          type: object
                          description: Associated timesheet information
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              example: pending
                        edit_history:
                          type: array
                          description: History of edits made to this entry
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: start_time
                              original_value:
                                type: string
                                example: '08:45:00'
                              new_value:
                                type: string
                                example: '09:00:00'
                              reason:
                                type: string
                                example: Employee correction
                              edited_by:
                                type: string
                                example: John Employee
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                        attendance_record:
                          type: object
                          nullable: true
                          description: Associated attendance record details
                          properties:
                            id:
                              type: integer
                              example: 789
                            check_in_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T09:00:00Z'
                            check_out_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T17:00:00Z'
                            status:
                              type: string
                              example: completed
                            requires_review:
                              type: boolean
                              example: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Timesheet entry not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    put:
      tags:
      - Timesheet Entries
      summary: Update timesheet entry
      description: |
        Update a timesheet entry with new times or notes. The associated
        timesheet must be editable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet entry ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - timesheet_entry
              properties:
                timesheet_entry:
                  type: object
                  properties:
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Corrected end time
                edit_reason:
                  type: string
                  description: Reason for the edit (for audit trail)
                  example: Employee requested time correction
      responses:
        '200':
          description: Timesheet entry updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  entry:
                    allOf:
                    - "$ref": "#/components/schemas/TimesheetEntry"
                    - type: object
                      properties:
                        timesheet:
                          type: object
                          description: Associated timesheet information
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              example: pending
                        edit_history:
                          type: array
                          description: History of edits made to this entry
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: start_time
                              original_value:
                                type: string
                                example: '08:45:00'
                              new_value:
                                type: string
                                example: '09:00:00'
                              reason:
                                type: string
                                example: Employee correction
                              edited_by:
                                type: string
                                example: John Employee
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                        attendance_record:
                          type: object
                          nullable: true
                          description: Associated attendance record details
                          properties:
                            id:
                              type: integer
                              example: 789
                            check_in_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T09:00:00Z'
                            check_out_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T17:00:00Z'
                            status:
                              type: string
                              example: completed
                            requires_review:
                              type: boolean
                              example: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Update failed or timesheet not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Timesheet Entries
      summary: Delete timesheet entry
      description: |
        Delete a manual timesheet entry. Only entries not tied to attendance
        records can be deleted, and the timesheet must be editable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet entry ID
        schema:
          type: integer
          example: 456
      responses:
        '204':
          description: Timesheet entry deleted successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot delete entry (tied to attendance or timesheet not editable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/summary":
    get:
      tags:
      - Pay Information
      summary: Get current pay period summary
      description: |
        Get a summary of the current pay period including hours worked,
        estimated pay, and timesheet status.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Pay period summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_summary:
                    type: object
                    properties:
                      pay_period:
                        type: object
                        properties:
                          start_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          end_date:
                            type: string
                            format: date
                            example: '2024-01-21'
                          period_type:
                            type: string
                            enum:
                            - weekly
                            - bi-weekly
                            - semi-monthly
                            - monthly
                            example: weekly
                      hours:
                        type: object
                        properties:
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                      estimated_pay:
                        type: object
                        properties:
                          regular_pay:
                            type: number
                            format: float
                            example: 1000.0
                          overtime_pay:
                            type: number
                            format: float
                            example: 93.75
                          total_pay:
                            type: number
                            format: float
                            example: 1093.75
                          hourly_rate:
                            type: number
                            format: float
                            example: 25.0
                          overtime_rate:
                            type: number
                            format: float
                            example: 37.5
                      timesheet_status:
                        type: string
                        enum:
                        - not_created
                        - pending
                        - submitted
                        - approved
                        - rejected
                        example: pending
                      last_updated:
                        type: string
                        format: date-time
                        example: '2024-01-20T10:30:00Z'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/history":
    get:
      tags:
      - Pay Information
      summary: Get pay history
      description: |
        Get historical pay information from approved timesheets and paychecks.
        Includes both estimated pay from timesheets and actual pay from paychecks.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Pay history
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_history:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum:
                          - timesheet
                          - paycheck
                          example: timesheet
                        id:
                          type: integer
                          example: 123
                        period_start:
                          type: string
                          format: date
                          example: '2024-01-15'
                        period_end:
                          type: string
                          format: date
                          example: '2024-01-21'
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 2.5
                        total_hours:
                          type: number
                          format: float
                          example: 42.5
                        status:
                          type: string
                          description: Status (for timesheets)
                          example: approved
                        approved_at:
                          type: string
                          format: date-time
                          description: Approval date (for timesheets)
                          example: '2024-01-22T14:30:00Z'
                        estimated_pay:
                          type: number
                          format: float
                          description: Estimated pay (for timesheets)
                          example: 1093.75
                        gross_pay:
                          type: number
                          format: float
                          description: Gross pay (for paychecks)
                          example: 1093.75
                        net_pay:
                          type: number
                          format: float
                          description: Net pay (for paychecks)
                          example: 850.0
                        pay_date:
                          type: string
                          format: date
                          description: Pay date (for paychecks)
                          example: '2024-01-26'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/pay_information/current_period":
    get:
      tags:
      - Pay Information
      summary: Get current pay period details
      description: |
        Get detailed information about the current pay period including
        timesheet status, estimated pay, and next payday.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current pay period details
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/next_payday":
    get:
      tags:
      - Pay Information
      summary: Get next payday information
      description: |
        Calculate and return the next payday date based on current
        pay period and business pay schedule.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Next payday information
          content:
            application/json:
              schema:
                type: object
                properties:
                  next_payday:
                    type: object
                    properties:
                      date:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until:
                        type: integer
                        example: 5
                      period_start:
                        type: string
                        format: date
                        example: '2024-01-15'
                      period_end:
                        type: string
                        format: date
                        example: '2024-01-21'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/ytd_summary":
    get:
      tags:
      - Pay Information
      summary: Get year-to-date summary
      description: |
        Get year-to-date summary including total hours worked,
        estimated and actual pay, and timesheet statistics.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Year-to-date summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  ytd_summary:
                    type: object
                    properties:
                      year:
                        type: integer
                        example: 2024
                      period:
                        type: object
                        properties:
                          start_date:
                            type: string
                            format: date
                            example: '2024-01-01'
                          end_date:
                            type: string
                            format: date
                            example: '2024-12-31'
                          days_elapsed:
                            type: integer
                            example: 20
                          days_remaining:
                            type: integer
                            example: 345
                      hours:
                        type: object
                        properties:
                          regular_hours:
                            type: number
                            format: float
                            example: 160.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 10.0
                          total_hours:
                            type: number
                            format: float
                            example: 170.0
                          average_weekly_hours:
                            type: number
                            format: float
                            example: 42.5
                      estimated_pay:
                        type: object
                        properties:
                          regular_pay:
                            type: number
                            format: float
                            example: 4000.0
                          overtime_pay:
                            type: number
                            format: float
                            example: 375.0
                          total_pay:
                            type: number
                            format: float
                            example: 4375.0
                      actual_pay:
                        type: object
                        properties:
                          gross_pay:
                            type: number
                            format: float
                            example: 4200.0
                          net_pay:
                            type: number
                            format: float
                            example: 3200.0
                          paychecks_count:
                            type: integer
                            example: 4
                      timesheets:
                        type: object
                        properties:
                          total_count:
                            type: integer
                            example: 4
                          approved_count:
                            type: integer
                            example: 3
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/paychecks":
    get:
      tags:
      - Paychecks
      summary: List user's paychecks
      description: |
        Get a paginated list of paychecks for the authenticated user.
        Requires Payroll Connect to be enabled and configured.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: start_date
        in: query
        description: Filter paychecks from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter paychecks to this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: year
        in: query
        description: Filter paychecks by year
        schema:
          type: integer
          example: 2024
      responses:
        '200':
          description: List of paychecks
          content:
            application/json:
              schema:
                type: object
                properties:
                  paychecks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        pay_date:
                          type: string
                          format: date
                          example: '2024-01-26'
                        pay_period_start:
                          type: string
                          format: date
                          example: '2024-01-15'
                        pay_period_end:
                          type: string
                          format: date
                          example: '2024-01-21'
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 2.5
                        total_hours:
                          type: number
                          format: float
                          example: 42.5
                        gross_pay:
                          type: number
                          format: float
                          example: 1093.75
                        net_pay:
                          type: number
                          format: float
                          example: 850.0
                        pay_frequency:
                          type: string
                          enum:
                          - weekly
                          - bi-weekly
                          - semi-monthly
                          - monthly
                          example: weekly
                        has_file:
                          type: boolean
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Payroll Connect not enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/paychecks/recent":
    get:
      tags:
      - Paychecks
      summary: Get recent paychecks
      description: 'Get the most recent paychecks (last 6 months, up to 12 paychecks).

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Recent paychecks
          content:
            application/json:
              schema:
                type: object
                properties:
                  recent_paychecks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        pay_date:
                          type: string
                          format: date
                          example: '2024-01-26'
                        pay_period_start:
                          type: string
                          format: date
                          example: '2024-01-15'
                        pay_period_end:
                          type: string
                          format: date
                          example: '2024-01-21'
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 2.5
                        total_hours:
                          type: number
                          format: float
                          example: 42.5
                        gross_pay:
                          type: number
                          format: float
                          example: 1093.75
                        net_pay:
                          type: number
                          format: float
                          example: 850.0
                        pay_frequency:
                          type: string
                          enum:
                          - weekly
                          - bi-weekly
                          - semi-monthly
                          - monthly
                          example: weekly
                        has_file:
                          type: boolean
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Payroll Connect not enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/paychecks/{id}":
    get:
      tags:
      - Paychecks
      summary: Get paycheck details
      description: |
        Get detailed paycheck information including earnings, deductions,
        taxes, and year-to-date totals.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Paycheck ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Paycheck details
          content:
            application/json:
              schema:
                type: object
                properties:
                  paycheck:
                    allOf:
                    - "$ref": "#/components/schemas/Paycheck"
                    - type: object
                      properties:
                        earnings:
                          type: object
                          properties:
                            regular_pay:
                              type: number
                              format: float
                              example: 1000.0
                            overtime_pay:
                              type: number
                              format: float
                              example: 93.75
                            holiday_pay:
                              type: number
                              format: float
                              example: 0.0
                            sick_pay:
                              type: number
                              format: float
                              example: 0.0
                            vacation_pay:
                              type: number
                              format: float
                              example: 0.0
                            bonus:
                              type: number
                              format: float
                              example: 0.0
                            commission:
                              type: number
                              format: float
                              example: 0.0
                            other_earnings:
                              type: number
                              format: float
                              example: 0.0
                            gross_pay:
                              type: number
                              format: float
                              example: 1093.75
                        deductions:
                          type: object
                          properties:
                            health_insurance:
                              type: number
                              format: float
                              example: 125.0
                            dental_insurance:
                              type: number
                              format: float
                              example: 15.0
                            vision_insurance:
                              type: number
                              format: float
                              example: 5.0
                            life_insurance:
                              type: number
                              format: float
                              example: 10.0
                            retirement_401k:
                              type: number
                              format: float
                              example: 50.0
                            retirement_roth:
                              type: number
                              format: float
                              example: 0.0
                            hsa:
                              type: number
                              format: float
                              example: 25.0
                            fsa:
                              type: number
                              format: float
                              example: 0.0
                            parking:
                              type: number
                              format: float
                              example: 20.0
                            union_dues:
                              type: number
                              format: float
                              example: 0.0
                            other_deductions:
                              type: number
                              format: float
                              example: 0.0
                            total_deductions:
                              type: number
                              format: float
                              example: 250.0
                        taxes:
                          type: object
                          properties:
                            federal_income_tax:
                              type: number
                              format: float
                              example: 150.0
                            state_income_tax:
                              type: number
                              format: float
                              example: 50.0
                            local_income_tax:
                              type: number
                              format: float
                              example: 10.0
                            social_security:
                              type: number
                              format: float
                              example: 67.81
                            medicare:
                              type: number
                              format: float
                              example: 15.86
                            unemployment_tax:
                              type: number
                              format: float
                              example: 0.0
                            disability_tax:
                              type: number
                              format: float
                              example: 5.0
                            other_taxes:
                              type: number
                              format: float
                              example: 0.0
                            total_taxes:
                              type: number
                              format: float
                              example: 298.67
                        employer_contributions:
                          type: object
                          properties:
                            health_insurance:
                              type: number
                              format: float
                              example: 200.0
                            retirement_match:
                              type: number
                              format: float
                              example: 25.0
                            social_security:
                              type: number
                              format: float
                              example: 67.81
                            medicare:
                              type: number
                              format: float
                              example: 15.86
                            unemployment:
                              type: number
                              format: float
                              example: 6.56
                            workers_comp:
                              type: number
                              format: float
                              example: 10.94
                            other_contributions:
                              type: number
                              format: float
                              example: 0.0
                            total_contributions:
                              type: number
                              format: float
                              example: 326.17
                        year_to_date:
                          type: object
                          properties:
                            gross_pay:
                              type: number
                              format: float
                              example: 4375.0
                            net_pay:
                              type: number
                              format: float
                              example: 3400.0
                            regular_hours:
                              type: number
                              format: float
                              example: 160.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 10.0
                            total_hours:
                              type: number
                              format: float
                              example: 170.0
                            federal_tax:
                              type: number
                              format: float
                              example: 600.0
                            state_tax:
                              type: number
                              format: float
                              example: 200.0
                            social_security:
                              type: number
                              format: float
                              example: 271.25
                            medicare:
                              type: number
                              format: float
                              example: 63.44
                        connection:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: ADP Payroll Connection
                            provider:
                              type: string
                              example: adp
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Paycheck not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/paychecks/{id}/download":
    get:
      tags:
      - Paychecks
      summary: Download paycheck stub
      description: 'Get download URL for paycheck stub file (if available).

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Paycheck ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Download information
          content:
            application/json:
              schema:
                type: object
                properties:
                  download_url:
                    type: string
                    format: uri
                    example: https://example.com/download/paycheck_123.pdf
                  filename:
                    type: string
                    example: paycheck_123_20240115.pdf
                  content_type:
                    type: string
                    example: application/pdf
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Paycheck file not available
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods":
    get:
      tags:
      - Pay Periods
      summary: List pay periods
      description: |
        Get a list of pay periods for a specified date range.
        Useful for displaying pay period calendar and history.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: start_date
        in: query
        description: Start date for pay period range
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: End date for pay period range
        schema:
          type: string
          format: date
          example: '2024-12-31'
      responses:
        '200':
          description: List of pay periods
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_periods:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          example: 2024-01-15_2024-01-21
                        start_date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        end_date:
                          type: string
                          format: date
                          example: '2024-01-21'
                        period_type:
                          type: string
                          enum:
                          - weekly
                          - bi-weekly
                          - semi-monthly
                          - monthly
                          example: weekly
                        days_in_period:
                          type: integer
                          example: 7
                        is_current:
                          type: boolean
                          example: true
                        is_future:
                          type: boolean
                          example: false
                        is_past:
                          type: boolean
                          example: false
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/current":
    get:
      tags:
      - Pay Periods
      summary: Get current pay period
      description: |
        Get detailed information about the current pay period including
        associated timesheet, estimated pay, and completion status.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current pay period
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/next":
    get:
      tags:
      - Pay Periods
      summary: Get next pay period
      description: 'Get information about the next pay period for planning purposes.

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Next pay period
          content:
            application/json:
              schema:
                type: object
                properties:
                  next_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/{id}":
    get:
      tags:
      - Pay Periods
      summary: Get pay period details
      description: |
        Get detailed information about a specific pay period.
        ID format: "YYYY-MM-DD_YYYY-MM-DD" (start_date_end_date).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Pay period ID (start_date_end_date format)
        schema:
          type: string
          example: 2024-01-15_2024-01-21
      responses:
        '200':
          description: Pay period details
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '400':
          description: Invalid pay period ID format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/{id}/summary":
    get:
      tags:
      - Pay Periods
      summary: Get pay period summary
      description: |
        Get comprehensive summary of a pay period including hours from
        different sources, attendance statistics, and pay calculations.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Pay period ID (start_date_end_date format)
        schema:
          type: string
          example: 2024-01-15_2024-01-21
      responses:
        '200':
          description: Pay period summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  period_summary:
                    type: object
                    properties:
                      period:
                        "$ref": "#/components/schemas/PayPeriod"
                      hours:
                        type: object
                        properties:
                          timesheet:
                            type: object
                            properties:
                              regular:
                                type: number
                                format: float
                                example: 40.0
                              overtime:
                                type: number
                                format: float
                                example: 2.5
                              total:
                                type: number
                                format: float
                                example: 42.5
                          attendance:
                            type: object
                            properties:
                              regular:
                                type: number
                                format: float
                                example: 39.5
                              overtime:
                                type: number
                                format: float
                                example: 2.0
                              total:
                                type: number
                                format: float
                                example: 41.5
                          scheduled:
                            type: number
                            format: float
                            example: 40.0
                          variance:
                            type: object
                            properties:
                              timesheet_vs_scheduled:
                                type: number
                                format: float
                                example: 2.5
                              attendance_vs_scheduled:
                                type: number
                                format: float
                                example: 1.5
                              timesheet_vs_attendance:
                                type: number
                                format: float
                                example: 1.0
                      attendance:
                        type: object
                        properties:
                          total_records:
                            type: integer
                            example: 5
                          completed_shifts:
                            type: integer
                            example: 5
                          missed_shifts:
                            type: integer
                            example: 0
                          late_arrivals:
                            type: integer
                            example: 1
                          early_departures:
                            type: integer
                            example: 0
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      timesheet_status:
                        type: string
                        enum:
                        - not_created
                        - pending
                        - submitted
                        - approved
                        - rejected
                        example: pending
                      completion_percentage:
                        type: number
                        format: float
                        example: 85.7
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '400':
          description: Invalid pay period ID format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/manual_time_entries":
    get:
      tags:
      - Manual Time Entries
      summary: List manual time entries
      description: |
        Get a list of manual time entries (entries not tied to attendance records).
        These are entries created manually by employees for missed punches or corrections.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: timesheet_id
        in: query
        description: Filter entries for specific timesheet
        schema:
          type: integer
          example: 123
      - name: start_date
        in: query
        description: Filter entries from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter entries to this date
        schema:
          type: string
          format: date
          example: '2024-01-31'
      - name: status
        in: query
        description: Filter by entry status
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
      - name: entry_type
        in: query
        description: Filter by entry type
        schema:
          type: string
          enum:
          - missing_punch
          - complete
          - overtime
          example: missing_punch
      responses:
        '200':
          description: List of manual time entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        timesheet_id:
                          type: integer
                          example: 123
                        date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        start_time:
                          type: string
                          format: time
                          example: '09:00:00'
                        end_time:
                          type: string
                          format: time
                          example: '17:00:00'
                        hours:
                          type: number
                          format: float
                          example: 8.0
                        entry_type:
                          type: string
                          enum:
                          - missing_punch
                          - regular
                          - overtime
                          example: missing_punch
                        edit_notes:
                          type: string
                          example: Manual entry for missed punch
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          example: pending
                        shift:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 789
                            title:
                              type: string
                              example: Morning Shift
                            location:
                              type: string
                              example: Main Office
                        timesheet:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              enum:
                              - pending
                              - submitted
                              - approved
                              - rejected
                              example: pending
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T18:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T18:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Manual Time Entries
      summary: Create manual time entry
      description: |
        Create a new manual time entry for times not captured through
        attendance records. The associated timesheet must be editable.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - manual_time_entry
              properties:
                manual_time_entry:
                  type: object
                  required:
                  - timesheet_id
                  - date
                  - start_time
                  - end_time
                  properties:
                    timesheet_id:
                      type: integer
                      example: 123
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      description: Hours worked (calculated if not provided)
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Manual entry for missed punch
                    shift_id:
                      type: integer
                      description: Associated shift ID (optional)
                      example: 456
      responses:
        '201':
          description: Manual time entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entry:
                    allOf:
                    - "$ref": "#/components/schemas/ManualTimeEntry"
                    - type: object
                      properties:
                        edit_history:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: end_time
                              original_value:
                                type: string
                                example: '16:30:00'
                              new_value:
                                type: string
                                example: '17:00:00'
                              reason:
                                type: string
                                example: Employee requested time correction
                              edited_by:
                                type: string
                                example: John Doe
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-16T09:00:00Z'
                        validation_warnings:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - overlap
                                - excessive_hours
                                - weekend_work
                                example: overlap
                              message:
                                type: string
                                example: This entry overlaps with another time entry
                                  on the same date
                        pay_calculation:
                          type: object
                          properties:
                            regular_hours:
                              type: number
                              format: float
                              example: 8.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 0.0
                            regular_pay:
                              type: number
                              format: float
                              example: 200.0
                            overtime_pay:
                              type: number
                              format: float
                              example: 0.0
                            total_pay:
                              type: number
                              format: float
                              example: 200.0
                            hourly_rate:
                              type: number
                              format: float
                              example: 25.0
                            overtime_rate:
                              type: number
                              format: float
                              example: 37.5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation failed or timesheet not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/manual_time_entries/{id}":
    get:
      tags:
      - Manual Time Entries
      summary: Get manual time entry details
      description: |
        Get detailed information about a specific manual time entry including
        edit history, validation warnings, and pay calculations.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Manual time entry ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Manual time entry details
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entry:
                    allOf:
                    - "$ref": "#/components/schemas/ManualTimeEntry"
                    - type: object
                      properties:
                        edit_history:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: end_time
                              original_value:
                                type: string
                                example: '16:30:00'
                              new_value:
                                type: string
                                example: '17:00:00'
                              reason:
                                type: string
                                example: Employee requested time correction
                              edited_by:
                                type: string
                                example: John Doe
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-16T09:00:00Z'
                        validation_warnings:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - overlap
                                - excessive_hours
                                - weekend_work
                                example: overlap
                              message:
                                type: string
                                example: This entry overlaps with another time entry
                                  on the same date
                        pay_calculation:
                          type: object
                          properties:
                            regular_hours:
                              type: number
                              format: float
                              example: 8.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 0.0
                            regular_pay:
                              type: number
                              format: float
                              example: 200.0
                            overtime_pay:
                              type: number
                              format: float
                              example: 0.0
                            total_pay:
                              type: number
                              format: float
                              example: 200.0
                            hourly_rate:
                              type: number
                              format: float
                              example: 25.0
                            overtime_rate:
                              type: number
                              format: float
                              example: 37.5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Manual time entry not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    put:
      tags:
      - Manual Time Entries
      summary: Update manual time entry
      description: |
        Update a manual time entry with new times or notes. The associated
        timesheet must be editable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Manual time entry ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - manual_time_entry
              properties:
                manual_time_entry:
                  type: object
                  properties:
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Corrected end time
                    shift_id:
                      type: integer
                      example: 456
                edit_reason:
                  type: string
                  description: Reason for the edit (for audit trail)
                  example: Employee requested time correction
      responses:
        '200':
          description: Manual time entry updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entry:
                    allOf:
                    - "$ref": "#/components/schemas/ManualTimeEntry"
                    - type: object
                      properties:
                        edit_history:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: end_time
                              original_value:
                                type: string
                                example: '16:30:00'
                              new_value:
                                type: string
                                example: '17:00:00'
                              reason:
                                type: string
                                example: Employee requested time correction
                              edited_by:
                                type: string
                                example: John Doe
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-16T09:00:00Z'
                        validation_warnings:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - overlap
                                - excessive_hours
                                - weekend_work
                                example: overlap
                              message:
                                type: string
                                example: This entry overlaps with another time entry
                                  on the same date
                        pay_calculation:
                          type: object
                          properties:
                            regular_hours:
                              type: number
                              format: float
                              example: 8.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 0.0
                            regular_pay:
                              type: number
                              format: float
                              example: 200.0
                            overtime_pay:
                              type: number
                              format: float
                              example: 0.0
                            total_pay:
                              type: number
                              format: float
                              example: 200.0
                            hourly_rate:
                              type: number
                              format: float
                              example: 25.0
                            overtime_rate:
                              type: number
                              format: float
                              example: 37.5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Update failed or timesheet not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Manual Time Entries
      summary: Delete manual time entry
      description: |
        Delete a manual time entry. Only entries not tied to attendance
        records can be deleted, and the timesheet must be editable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Manual time entry ID
        schema:
          type: integer
          example: 456
      responses:
        '204':
          description: Manual time entry deleted successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot delete entry (tied to attendance or timesheet not editable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/service_desk":
    get:
      tags:
      - Service Desk
      summary: List support tickets
      description: "Retrieve all SERVICE DESK SUPPORT TICKETS and HELP REQUESTS for
        the current user (NOT forms or form templates). Returns both tickets created
        by the user (as requester) and tickets assigned to the user (as assignee).\n\nUse
        this endpoint to:\n- List all my support tickets\n- Show my help requests
        \ \n- View my IT tickets\n- Get support request list\n- See incident reports\n-
        Check service desk tickets\n- Find support tickets by status or priority\n\nSupports
        filtering by status and priority. ⚠️ This is for SUPPORT TICKETS only, not
        for forms.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Filter by ticket status
        schema:
          type: string
          enum:
          - submitted
          - assigned
          - in_progress
          - resolved
          - closed
      - name: priority
        in: query
        description: Filter by priority level
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      responses:
        '200':
          description: List of tickets retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  count:
                    type: integer
                    example: 5
                  tickets:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SupportTicketSummary"
    post:
      tags:
      - Service Desk
      summary: Create support ticket
      description: 'Create a new support ticket or help request. Use this to create
        ticket, submit request, report issue, request help, open ticket, file support
        request, log incident, create help request, or submit support ticket.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - ticket
              properties:
                ticket:
                  type: object
                  required:
                  - title
                  - description
                  properties:
                    title:
                      type: string
                      description: Brief title of the issue
                      example: Laptop overheating issue
                    description:
                      type: string
                      description: Detailed description of the problem
                      example: My laptop has been overheating when running multiple
                        applications
                    request_type:
                      type: string
                      enum:
                      - general
                      - it_support
                      - hr_support
                      - facilities
                      - equipment
                      - training
                      - access
                      - travel_expense
                      - health_safety
                      - payroll_benefits
                      example: it_support
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: medium
                    location_id:
                      type: integer
                      nullable: true
                      example: 1
      responses:
        '201':
          description: Ticket created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Support ticket created successfully
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/service_desk/search":
    get:
      tags:
      - Service Desk
      summary: Search support tickets
      description: |
        Search and filter SERVICE DESK SUPPORT TICKETS with advanced query capabilities (NOT for forms). Supports full-text search across ticket title, description, and resolution notes with pagination and sorting.

        Use this endpoint to:
        - Search support tickets by keyword
        - Find help requests
        - Filter tickets by status or priority
        - Find specific support issues
        - Search IT ticket history
        - Look for related support tickets
        - Find tickets by type (IT, HR, facilities, etc.)

        ⚠️ This searches SUPPORT TICKETS only, not forms or form submissions.
      security:
      - BearerAuth: []
      parameters:
      - name: query
        in: query
        description: Search term (searches title, description, resolution notes)
        schema:
          type: string
          example: laptop
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - submitted
          - assigned
          - in_progress
          - resolved
          - closed
      - name: priority
        in: query
        description: Filter by priority
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: request_type
        in: query
        description: Filter by request type
        schema:
          type: string
          example: it_support
      - name: created_after
        in: query
        description: Filter tickets created after this date
        schema:
          type: string
          format: date-time
      - name: created_before
        in: query
        description: Filter tickets created before this date
        schema:
          type: string
          format: date-time
      - name: sort_by
        in: query
        description: Field to sort by
        schema:
          type: string
          default: created_at
          enum:
          - created_at
          - updated_at
          - priority
          - status
      - name: sort_order
        in: query
        description: Sort direction
        schema:
          type: string
          default: desc
          enum:
          - asc
          - desc
      - name: page
        in: query
        description: Page number
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Results per page (max 100)
        schema:
          type: integer
          default: 20
          maximum: 100
      responses:
        '200':
          description: Search results with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total_pages:
                    type: integer
                  tickets:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SupportTicketSummary"
  "/service_desk/service_catalog":
    get:
      tags:
      - Service Desk
      summary: Browse service catalog
      description: |
        Browse the service catalog to see available request types for submitting
        help requests. Returns all enabled service types organized by popularity.
        Supports search and category filtering.

        Accessible to all authenticated users (not admin-only).
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        description: Search term to filter service types by title, description, or
          examples
        schema:
          type: string
      - name: category
        in: query
        description: Filter by category name
        schema:
          type: string
      - name: department_id
        in: query
        description: Filter by service department ID
        schema:
          type: integer
      responses:
        '200':
          description: Service catalog retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                  service_types:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceCatalogType"
                  popular_types:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceCatalogType"
                  categories:
                    type: array
                    items:
                      type: string
        '401':
          description: Authentication required
  "/service_desk/kb/search":
    get:
      tags:
      - Service Desk
      summary: Search the knowledge base
      description: |
        Search the service desk knowledge base (help articles, FAQs, documents)
        for self-service answers. Accessible to all authenticated members.

        Uses the same hybrid search the Ask AI service desk agent uses, with
        the same role-based visibility filtering — callers only see articles
        their role permits. Results are capped at 10 and never cached.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        description: Search query
        schema:
          type: string
          example: VPN password reset
      - name: limit
        in: query
        description: Maximum results (default 10, max 10)
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 10
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  query:
                    type: string
                  count:
                    type: integer
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/KbSearchResult"
        '400':
          description: Missing search query
  "/service_desk/{id}":
    get:
      tags:
      - Service Desk
      summary: Get support ticket details
      description: "Retrieve detailed information about a specific SERVICE DESK SUPPORT
        TICKET or HELP REQUEST (NOT a form). This endpoint returns support ticket
        status, assigned agent, priority level, complete description, comment history,
        and resolution timeline.\n\nUse this endpoint to:\n- Check support ticket
        status (\"What's the status of ticket #70?\")\n- View help request details
        (\"Show me ticket 70\")\n- See who is handling a support ticket\n- Get IT
        support ticket information  \n- View incident report status\n- Check service
        desk request progress\n- Find out about a specific support request or help
        ticket\n\n⚠️ IMPORTANT: This is for SUPPORT TICKETS only, not for forms, templates,
        or form submissions. Ticket IDs are from the Service Desk system, not the
        Forms app.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID number
        schema:
          type: integer
      responses:
        '200':
          description: Ticket details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '404':
          description: Ticket not found or access denied
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    patch:
      tags:
      - Service Desk
      summary: Update support ticket
      description: |
        Update the editable fields of an existing support ticket.

        Field access is role-based:
        - Requesters may update **title** and **description** on their own ticket,
          only while the ticket is still editable (status submitted, queued, or assigned).
        - Privileged callers (business admins, super admins, help desk agents,
          or the current assignee) may additionally update **priority**,
          **request_type**, and **service_department_id**.

        Additional rules:
        - No one can edit resolved, closed, cancelled, or rejected tickets.
        - Priority changes also respect the business priority source mode
          (in "system"/"agent" modes only service desk staff may set priority)
          and the prevent-self-service-on-own-tickets setting.
        - **Status cannot be changed here** — use PATCH /service_desk/{id}/status.
        - Changing request_type may move the ticket to pending_approval if the
          new type requires approval.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - ticket
              properties:
                ticket:
                  type: object
                  properties:
                    title:
                      type: string
                      example: Laptop overheating issue (updated)
                    description:
                      type: string
                      example: Updated description with more details
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      description: Privileged callers only; also gated by priority
                        source mode
                    request_type:
                      type: string
                      description: Privileged callers only
                      example: it_support
                    service_department_id:
                      type: integer
                      nullable: true
                      description: Privileged callers only
      responses:
        '200':
          description: Ticket updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: Not permitted to edit this ticket (or this field)
        '404':
          description: Ticket not found or access denied
        '422':
          description: Validation error
  "/service_desk/{id}/assign":
    post:
      tags:
      - Service Desk
      summary: Assign or transfer ticket
      description: |
        Assign a support ticket to a user, transfer it to another user, or move
        it into a team queue. Privileged only: business admins, super admins,
        managers, help desk agents, or the ticket's current assignee.

        Rules:
        - Closed, cancelled, or rejected tickets cannot be assigned/transferred.
        - Tickets pending approval must be approved or rejected first.
        - When prevent-self-service is enabled, the requester cannot assign
          their own ticket to themselves.
        - Fires the same notifications and milestone tracking as the web app.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_type:
                  type: string
                  enum:
                  - user
                  - team
                  default: user
                  description: Assign to an individual user or transfer to a team
                    queue
                assignee_id:
                  type: integer
                  description: Required when target_type is "user"
                  example: 42
                support_team_id:
                  type: integer
                  description: Required when target_type is "team"
                  example: 3
                transfer_reason:
                  type: string
                  description: Optional reason recorded on transfer
                  example: Better suited for the networking team
                notify_assignee:
                  type: boolean
                  default: true
                  description: Send the assignment/transfer notification (default
                    true)
      responses:
        '200':
          description: Ticket assigned or transferred successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: Not authorized to assign or transfer this ticket
        '404':
          description: Ticket, assignee, or team not found
        '422':
          description: Ticket state does not allow assignment, or missing target
  "/service_desk/{id}/comments":
    get:
      tags:
      - Service Desk
      summary: Get ticket comments
      description: 'Retrieve all comments and updates on a support ticket. Use this
        to view ticket comments, see conversation history, check what was discussed,
        read agent responses, view ticket updates, or see comment thread.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      responses:
        '200':
          description: List of comments retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  ticket_id:
                    type: integer
                  comments_count:
                    type: integer
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TicketComment"
        '404':
          description: Ticket not found or access denied
    post:
      tags:
      - Service Desk
      summary: Add comment to ticket
      description: 'Add a comment or update to an existing support ticket. Use this
        to add comment, update ticket, provide more information, respond to agent,
        add details, reply to ticket, post update, or add note to ticket.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: object
                  required:
                  - content
                  properties:
                    content:
                      type: string
                      description: Comment text
                      example: I've restarted the laptop and the issue persists
                    internal:
                      type: boolean
                      description: Internal note (not visible to requester)
                      default: false
      responses:
        '201':
          description: Comment added successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  comment:
                    "$ref": "#/components/schemas/TicketComment"
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '404':
          description: Ticket not found
        '422':
          description: Validation error
  "/service_desk/{id}/status":
    patch:
      tags:
      - Service Desk
      summary: Update ticket status
      description: 'Change the status of a support ticket (resolve, close, reopen,
        mark in progress). Use this to close ticket, resolve ticket, reopen ticket,
        mark in progress, update status, change ticket state, mark resolved, mark
        closed, or start work on ticket.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action_type
              properties:
                action_type:
                  type: string
                  description: Status action to perform
                  enum:
                  - resolve
                  - close
                  - reopen
                  - in_progress
                  example: resolve
                notes:
                  type: string
                  description: Optional notes about status change (resolution notes,
                    closing reason, etc.)
                  example: Issue was fixed by restarting the VPN service
      responses:
        '200':
          description: Status updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '400':
          description: Invalid action type
        '403':
          description: Not authorized to perform this action
        '404':
          description: Ticket not found
        '422':
          description: Status update failed
  "/ask_ai/messages":
    post:
      tags:
      - Ask AI
      summary: Send message to AI
      description: |
        Send a message to the AI assistant and initiate streaming response.

        **Conversation Restoration:**
        If no `conversation_id` is provided, the API will automatically try to restore
        the user's most recent active conversation. This ensures conversation history
        persists across logout/login cycles without requiring the client to store
        conversation IDs.

        The response includes a `conversation_id` that the client should use to:
        1. Subscribe to the `AiResponseChannel` WebSocket for streaming responses
        2. Reference this conversation in subsequent requests

        **WebSocket Subscription:**
        ```javascript
        const channel = consumer.subscriptions.create(
          { channel: "AiResponseChannel", conversation_id: response.conversation_id },
          {
            received(data) {
              switch(data.type) {
                case 'chunk': // Streaming text chunk
                case 'complete': // Full response with metadata
                case 'error': // Error occurred
                case 'status': // Status update (thinking, generating)
              }
            }
          }
        );
        ```

        Use this endpoint to:
        - Ask questions about schedules, PTO, policies
        - Get help with IT issues
        - Request information from company knowledge base
        - Perform actions like submitting time off requests
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                  description: The user's message to the AI
                  example: How many PTO days do I have left?
                conversation_id:
                  deprecated: true
                  type: string
                  format: uuid
                  description: |
                    **Accepted for backward compatibility but IGNORED.** The server always
                    resolves the thread itself — it continues the caller's most recent
                    active conversation, or mints a new id when there is none. Sending a
                    value here changes nothing except a log line.

                    This is deliberate, and it is a security fix rather than an oversight:
                    the resolved id becomes the key of the WebSocket authorization record
                    that `AiResponseChannel` reads to decide who may subscribe to a stream,
                    so honouring a client-supplied value would let any authenticated user
                    take over another user's stream.

                    Clients MUST read the `conversation_id` returned in the response (and
                    `websocket.subscription.conversation_id`, which is the same value) and
                    subscribe with THAT — never with an id they generated or cached.
                  example: 550e8400-e29b-41d4-a716-446655440000
                mode:
                  deprecated: true
                  type: string
                  description: |
                    **Accepted and validated, but currently has NO effect.** Nothing on the
                    streaming path reads it — `AskAiStreamingJob` forwards `system_context`,
                    `ask_ai_context`, `conversation_history` and `page_context` to the agent
                    pipeline and never `mode` — so every value routes exactly like `general`.
                    An unrecognised value is silently treated as `general` rather than
                    rejected.

                    Kept in the contract because existing native builds send it and it is
                    the seam a future forward would use; do not build client behaviour on
                    the assumption that it changes routing.
                  enum:
                  - general
                  - help
                  - scheduling
                  default: general
                system_context:
                  type: string
                  description: Optional additional context for the AI (hidden from
                    user)
      responses:
        '200':
          description: Message accepted, streaming initiated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MessageResponse"
        '403':
          description: |
            Ask AI is not enabled for this business or not visible to this user
            (`app_disabled`), or the API token lacks the `write:chat` scope
            (`insufficient_permissions`). Full-access and `admin` tokens, and
            session-authenticated callers, are unaffected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Invalid request (missing message)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: AI service unavailable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/conversations/{id}":
    get:
      tags:
      - Ask AI
      summary: Get conversation details
      description: 'Retrieve metadata about a specific conversation including status
        and message count.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Conversation session ID (UUID)
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Conversation details retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  conversation:
                    "$ref": "#/components/schemas/Conversation"
        '404':
          description: Conversation not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Ask AI
      summary: Clear conversation
      description: |
        Clear conversation history. By default, only clears messages.
        Use `clear_everything=true` to also clear learned facts and cache.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Conversation session ID (UUID)
        schema:
          type: string
          format: uuid
      - name: clear_everything
        in: query
        description: If true, clears all memory including learned facts
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Conversation cleared successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Conversation cleared
                  cleared:
                    type: object
                    properties:
                      messages:
                        type: boolean
                      facts:
                        type: boolean
                      cache:
                        type: boolean
        '404':
          description: Conversation not found
  "/ask_ai/conversations/{id}/messages":
    get:
      tags:
      - Ask AI
      summary: Get conversation messages
      description: |
        Retrieve paginated message history for a conversation.
        Messages are returned in chronological order (oldest first).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Conversation session ID (UUID)
        schema:
          type: string
          format: uuid
      - name: page
        in: query
        description: Page number (1-based)
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Messages per page (max 100)
        schema:
          type: integer
          default: 20
          maximum: 100
      - name: q
        in: query
        description: |
          Optional search term (max 200 chars). Restricts the result to messages
          matching it, using the same full-text + substring match the web History
          page uses. `pagination.total_count` and `total_pages` describe the
          FILTERED set, so paging works unchanged while a search is active.
        schema:
          type: string
          maxLength: 200
      responses:
        '200':
          description: Messages retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  conversation_id:
                    type: string
                    format: uuid
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Message"
                  pagination:
                    "$ref": "#/components/schemas/Pagination"
        '404':
          description: Conversation not found
  "/ask_ai/help":
    get:
      tags:
      - Ask AI
      summary: Get AI capabilities and examples
      description: |
        Get list of available AI agents with their capabilities and example questions.
        Use this to show users what they can ask the AI assistant.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Help examples retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  agents:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AgentInfo"
  "/ask_ai/cancel":
    post:
      tags:
      - Ask AI
      summary: Cancel active AI request
      description: |
        Cancel an in-progress AI request for a conversation.
        Sends a cancellation signal via WebSocket.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - conversation_id
              properties:
                conversation_id:
                  type: string
                  format: uuid
                  description: The conversation to cancel
                  example: 550e8400-e29b-41d4-a716-446655440000
      responses:
        '200':
          description: Cancellation signal sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Cancellation signal sent
                  conversation_id:
                    type: string
                    format: uuid
        '404':
          description: Conversation not found
        '422':
          description: Invalid request (missing conversation_id)
  "/ask_ai/settings":
    get:
      tags:
      - Ask AI
      summary: Get AI feature settings and current conversation
      description: "Get feature settings for the mobile client to control UI behavior.\n\n**Also
        returns the user's current/latest active conversation** to support\nsession
        restoration after logout/login. Mobile apps should call this endpoint\non
        startup and use the returned `current_conversation.id` to restore the\nuser's
        previous conversation.\n\nReturns minimal feature toggles including:\n- `create_support_ticket_enabled`:
        Whether \"Create Support Ticket\" button should be shown\n- `function_calls_enabled`:
        Whether AI can execute actions on behalf of the user\n- `schedule_queries_enabled`:
        Whether scheduling-related queries are available\n- `general_questions_enabled`:
        Whether general Q&A is enabled\n- `voice_mode_enabled`: Whether voice mode
        is available (check before showing voice button)\n- `voice_mode_limit_info`:
        Usage limits for voice mode (daily/session limits, remaining minutes)\n\n**Voice
        Mode Check:**\nAlways check `voice_mode_enabled` before showing voice UI.
        If enabled, \n`voice_mode_limit_info` provides usage data to display to users.\n\n**Session
        Restoration:**\nThe `current_conversation` field contains the user's most
        recent active conversation,\nor `null` if no active conversation exists. Use
        this to restore conversation history\nafter the user logs out and logs back
        in.\n"
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Settings retrieved successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiSettings"
        '401':
          description: Authentication required
  "/ask_ai/voice_token":
    post:
      tags:
      - Ask AI
      - Voice Mode
      summary: Get voice mode token
      description: "Generate an ephemeral token for OpenAI Realtime API voice mode.\n\n**Voice
        Mode Architecture:**\nVoice mode uses a dual-connection approach:\n1. **OpenAI
        WebRTC**: Direct audio streaming between mobile app and OpenAI\n2. **ActionCable
        WebSocket**: Backend queries and response handling\n\n**Flow:**\n1. Call this
        endpoint to get ephemeral token and session configuration\n2. Connect to OpenAI
        via WebRTC using the ephemeral token\n3. Connect to ActionCable and subscribe
        to `VoiceRealtimeChannel`\n4. User speaks → OpenAI transcribes → Send transcript
        to ActionCable\n5. Backend processes query → Response sent via ActionCable\n6.
        Send response text to OpenAI for TTS → Audio plays to user\n\n**Rate Limiting:**\n-
        30 minutes per user per day\n- 15 minutes max per session\n\nSee the [Mobile
        Voice Mode Guide](/api-docs/guides/ask-ai-mobile.md#voice-mode-integration)
        \nfor detailed implementation instructions.\n"
      security:
      - BearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                voice:
                  type: string
                  description: |
                    OpenAI voice to use for TTS. Validated against the full supported set
                    (`OpenaiTtsService::VOICES`); an unrecognised value falls back to the
                    business's configured "AI Voice" setting, or to `marin` when none is set,
                    rather than erroring.
                  enum:
                  - alloy
                  - ash
                  - ballad
                  - coral
                  - echo
                  - fable
                  - nova
                  - onyx
                  - sage
                  - shimmer
                  - verse
                  - marin
                  - cedar
                  default: marin
      responses:
        '200':
          description: Voice token generated successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceTokenResponse"
        '402':
          description: Insufficient voice minutes balance
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceErrorResponse"
        '403':
          description: |
            Voice mode is not enabled for this business (`feature_disabled`), Ask AI is
            not enabled/visible for this user (`app_disabled`), or the API token lacks
            the `write:chat` scope (`insufficient_permissions`). Full-access and `admin`
            tokens, and session-authenticated callers, are unaffected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '429':
          description: |
            Daily voice usage limit reached (`voice_limit_reached`), or the per-user
            hourly mint cap was exceeded (`rate_limited`). `error.details` carries the
            usage meter (`daily_used`, `daily_limit`, `remaining`, `session_limit`) plus
            `open_sessions` / `reserved_minutes` — the minutes already committed by
            sessions this user has not ended, which count toward the daily cap even
            though no duration has been recorded for them yet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: AI service not configured
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/voice/sessions/{session_id}":
    get:
      tags:
      - Ask AI
      - Voice Mode
      summary: Get voice session status
      description: |
        Check the status of a voice session. Useful for reconnection handling
        when the mobile app needs to verify if a session is still active.
      security:
      - BearerAuth: []
      parameters:
      - name: session_id
        in: path
        required: true
        description: Voice session ID (UUID returned from voice_token endpoint)
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Session status retrieved
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceSessionStatus"
        '404':
          description: Session not found or doesn't belong to current user
  "/ask_ai/voice/sessions/{session_id}/end":
    post:
      tags:
      - Ask AI
      - Voice Mode
      summary: End voice session
      description: |
        Explicitly end a voice session for graceful cleanup and billing finalization.

        **Important:** Always call this endpoint when ending a voice session to ensure
        proper billing and cleanup, even if the WebSocket connection was lost unexpectedly.

        This endpoint:
        1. Calculates final session duration
        2. Charges the appropriate billing amount
        3. Cleans up session data from cache

        If the session was already ended (via ActionCable or timeout), this endpoint
        returns success with the already-recorded billing information.
      security:
      - BearerAuth: []
      parameters:
      - name: session_id
        in: path
        required: true
        description: Voice session ID (UUID returned from voice_token endpoint)
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Session ended successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceSessionEndResponse"
        '404':
          description: Session not found or doesn't belong to current user
  "/notifications":
    get:
      tags:
      - Notifications
      summary: List notifications
      description: |
        Retrieve notifications for the current user. Returns paginated notifications
        scoped to the current business.

        Use this endpoint to:
        - List all active notifications
        - Filter by read/unread/archived status
        - Filter to ONE activity type (Wikis, Announcements, Messages, …)
        - Paginate through notification history

        **`filter` carries two vocabularies.**

        *Read state* — the values that shipped first, unchanged:

        - `unread` — only unread notifications
        - `read` — only read (non-archived) notifications
        - `archived` — only archived notifications
        - `all` (or omit) — all active (non-archived) notifications

        *Activity subject* — one activity type. The value is a subject `key`
        exactly as `GET /notifications/home` hands it out on any Activity
        section or Needs-you entry (`wikis`, `system_announcements`,
        `broadcast_messages`, `direct_messages`, `training`, `forms`,
        `news_feed_notifications`, `kind:dm`, `kind:broadcast`, …). This is what
        the Activity group's **See all →** opens, and the same key the web
        "See all wikis" link puts in `?subject=`.

        **Every registered notification category is a valid value**, plus the
        four `kind:` fallback buckets (`kind:broadcast`, `kind:agent`,
        `kind:dm`, `kind:system`) that an uncategorised notification lands in —
        not merely the subjects that happen to have rows right now. The set is
        derived from the category registry, so it grows on its own as apps are
        added and never needs a client release.

        Pass back the key you were given rather than hardcoding a list. The
        subjects one user sees are a small slice of the whole set — a user with
        no schedule notifications never sees `schedule_management` on their own
        home screen, while most of their colleagues do — so a hardcoded list
        built from one account's inbox will be wrong for everyone else.

        A valid key you have no notifications in returns an empty `200`, not an
        error. Matching is case- and separator-insensitive **over the key**:
        `system_announcements`, `system-announcements` and
        `System Announcements` are one value. It does not match display
        **labels** — the label of `news_feed_notifications` is "News Feed", and
        `filter=News Feed` is a `400` whose message points at the real key. Pass
        back the `key`, not the `label`.

        Read states (`unread`, `read`, `archived`, `all`) are matched the same
        way, so `filter=Unread` and `filter=unread` are one value too.

        A subject filter lists that subject across the **visible inbox** —
        snoozed, expired and archived rows are excluded, the same visibility
        `GET /notifications/home` counts under.

        It covers **both zones**: a subject can hold open asks as well as
        activity, and the key you pass back may have come from either section, so
        the list holds that subject's `activity.sections[].count` **plus** its
        `needs_you.entries[].count`. Expect a list longer than the Activity chip
        alone whenever the subject also has something waiting on the user; the
        rows carry `action_required` / `action_completed_at`, so a client that
        wants one zone can split them itself.

        Add `folder=` to narrow it further (`folder=unread&filter=wikis`)
        or `q=` to search within it.

        `subject=<key>` is the older spelling of the same narrowing and still
        works. Passing both is fine when they agree; passing two DIFFERENT
        subjects is a `400 conflicting_subject_filter` rather than a silently
        dropped filter.

        A `filter` value that is neither a read state nor a known subject key is
        a `400 invalid_filter` — the endpoint will not answer "wikis" with every
        notification you have. The error message suggests near-miss keys.

        **Ordering.** Every folder is returned in the same order the web Inbox
        uses, so a native client can render the list as-is and match what the
        user sees on the web. There are two rules.

        *Archived* is ordered by `archived_at` descending — most recently
        ARCHIVED first, nulls last. The archive is a filing cabinet, so the item
        the user just archived is on top regardless of how old the underlying
        event is. Ties fall back to `created_at` then `id`, both descending (a
        bulk archive stamps one identical `archived_at` across the whole
        selection, so the tiebreak is routine).

        *Every other folder* is ordered by triage:

        1. open action requests first (`action_required` and not yet completed)
        2. then `priority`, high to low
        3. then `created_at`, newest first
        4. then `id` descending, so paging can't repeat or skip a row that ties
           another on `created_at`

        Clients should render in the order received rather than re-sorting.
        `created_at` is only a tiebreaker in both rules, and sorting the archive
        by it reproduces the bug this ordering exists to fix. Unarchiving does
        not reorder anything: `archived_at` is cleared and the item returns to
        its ranked position in the active list.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        description: 'Read state (`unread`, `read`, `archived`, `all`) OR an activity
          subject key from `GET /notifications/home`. Not an enum: the subject half
          is derived from the tenant''s registered notification categories and grows
          with the apps installed. See the endpoint description.'
        schema:
          type: string
        examples:
          unread:
            summary: Read state — unread only
            value: unread
          wikis:
            summary: Activity type — Wikis
            value: wikis
          announcements:
            summary: Activity type — System Announcements
            value: system_announcements
          messages:
            summary: Activity type — Direct Messages
            value: direct_messages
      - name: subject
        in: query
        description: Narrow to one activity subject — the older spelling of `filter=<key>`,
          and what the web "See all wikis" link uses. Unlike `filter`, an unrecognised
          key returns an empty list rather than an error.
        schema:
          type: string
      - name: folder
        in: query
        description: Inbox folder to list. Composes with `filter=<subject>` / `subject=`.
          `messages` is accepted but served by the chat API, so it returns an empty
          list here — read mail through the chat endpoints. `mail-alerts` is the folder
          that lists the notifications ABOUT mail.
        schema:
          type: string
          enum:
          - inbox
          - unread
          - read
          - action
          - broadcasts
          - announcements
          - system
          - agents
          - snoozed
          - archived
          - mail-alerts
          - messages
      - name: q
        in: query
        description: Free-text search over notification title and content.
        schema:
          type: string
      - name: page
        in: query
        description: Page number (1-indexed)
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Items per page (max 100)
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Paginated list of notifications
          content:
            application/json:
              schema:
                type: object
                required:
                - notifications
                - meta
                properties:
                  notifications:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Notification"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
        '400':
          description: "`invalid_filter` — the `filter` value is neither a read state
            nor a known activity subject key. Or `conflicting_subject_filter` — `filter`
            and `subject` named two different subjects."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/notifications/{id}":
    get:
      tags:
      - Notifications
      summary: Get a notification
      description: |
        Retrieve a single notification by ID. The notification must belong to the
        current user and current business.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification details
          content:
            application/json:
              schema:
                type: object
                required:
                - notification
                properties:
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: notification_not_found
                      message:
                        type: string
                        example: Notification not found
  "/notifications/unread_count":
    get:
      tags:
      - Notifications
      summary: Get unread notification count
      description: |
        Returns the count of unread notifications for the current user
        within the current business. Useful for badge counts on mobile.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unread count
          content:
            application/json:
              schema:
                type: object
                required:
                - count
                properties:
                  count:
                    type: integer
                    description: Number of unread notifications
                    example: 5
        '401':
          description: Unauthorized
  "/notifications/home":
    get:
      tags:
      - Notifications
      summary: Notifications home screen (Needs you + Activity)
      description: |
        Everything needed to draw the notifications front door — the screen behind
        the bell icon — in ONE round-trip, in the same two sections and the same
        order the web `/inbox` and `/m/inbox` render. Built from the same
        `Inbox::SubjectGrouping` the web uses, so a notification cannot land in a
        different group on mobile than it does on the desktop.

        Both sections group by **subject** — what an item is ABOUT ("time-off
        requests", "the ticket I filed") — rather than by how it was produced.
        Subjects come from the categories the user's own notification-preferences
        page already names, so a group heading always has a preference toggle
        behind it.

        ### Needs you

        Open asks waiting on this user: `action_required` and not yet completed.
        This is the zone whose count the bell badge reports and the only zone a
        user can empty by doing the work.

        Ordered **urgent first, then longest waiting first** (`ordering:
        "longest_waiting_first"`). Age alone would bury a brand-new urgent ask
        under a week-old routine one; priority alone lets an ask rot quietly.
        Render entries in the order received.

        Each entry carries the four facts the web row shows:

        | field | renders as |
        |---|---|
        | `count` | the count pill |
        | `unread_count` | the "N new" pill (omit the pill when 0) |
        | `high_priority` | the **Urgent** pill |
        | `oldest_waiting_label` | "oldest waiting 4 days" |

        **Adaptive collapse.** A subject only becomes a group once it holds 3+
        items. Below that its asks arrive as single rows with `group: false`,
        because grouping a handful of notifications produces four groups of one —
        strictly worse than a short list. Read `group` to decide which shape to
        draw; both carry the same keys, so one list renderer handles both.

        For a group, `title` is the subject label; for a single row it is the
        notification's own title. `notifications` carries the entry's rows so a
        client can expand without a second request.

        ### Activity

        Everything that merely happened, **counted and never listed**: the zone
        is unbounded (it is the entire history of things that happened to you)
        and the point of it is that the user does not have to read the pile. Each
        subsection carries exactly four facts plus its identity — `count`,
        `unread_count`, `newest_at` (when the latest notification in it was
        received) and `icon`. There is deliberately no `notifications` array;
        fetch one subsection's rows with `GET /notifications?filter=<key>` (or the
        equivalent `?subject=<key>`) when
        the user expands it.

        Ordered **biggest pile first** (`ordering: "largest_first"`).

        ### Visibility and the empty state

        Both sections show only what the inbox can currently display: archived,
        snoozed and expired rows are excluded. `empty` is true when both zones
        are empty. `snoozed_count` is reported only when nothing is waiting on
        the user, and exists to explain a zero — a snoozed ask the bell had
        counted is invisible here, and without naming it "You're all caught up"
        reads as a contradiction.

        Related endpoints: `GET /notifications/open_asks_count` for the bell
        badge alone, and `GET /notifications?filter=<key>` for one subject's
        rows.
      security:
      - BearerAuth: []
      parameters:
      - name: subject
        in: query
        required: false
        description: |
          Narrow BOTH sections to one subject key (a `key` from either section), matching the web `?subject=` filter. Omit it for both zones in full.

          Spelling is case- and separator-insensitive over the key, exactly as on `?filter=`. An unrecognised key is a `400 invalid_subject` rather than an empty screen: narrowing both zones to nothing would answer `empty: true` and read as "you're all caught up" when the truth is that the filter does not exist. The error message names near-miss keys.
        schema:
          type: string
        example: leave_notifications
      responses:
        '200':
          description: The two zones, sectioned and ordered as the web renders them
          content:
            application/json:
              schema:
                type: object
                required:
                - needs_you
                - activity
                - empty
                - snoozed_count
                properties:
                  needs_you:
                    "$ref": "#/components/schemas/NotificationsHomeNeedsYou"
                  activity:
                    "$ref": "#/components/schemas/NotificationsHomeActivity"
                  empty:
                    type: boolean
                    description: True when both sections are empty.
                    example: false
                  snoozed_count:
                    type: integer
                    description: 'Items the user parked, which are hidden from both
                      sections and from the bell. Reported only when nothing is waiting
                      (otherwise 0), because its only job is to explain a zero — and
                      narrowed by `subject` when one was given, so it explains the
                      zero on the screen the caller is looking at rather than naming
                      parked items from other subjects.

                      '
                    example: 0
        '400':
          description: "`invalid_subject` — `subject` names no known subject key.
            The message names near-miss keys."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/notifications/dismiss_subject":
    post:
      tags:
      - Notifications
      summary: Dismiss a whole Activity subsection ("Dismiss these")
      description: |
        Clear one Activity subsection in a single call — the native twin of the
        **Dismiss these** button the web `/inbox` and `/m/inbox` render at the
        foot of an expanded Activity group. Pass a `key` straight off
        `GET /notifications/home` → `activity.sections[].key` and every
        notification that section counted is archived.

        Activity items are never individually actionable, which is why the zone
        offers no per-row verb to loop instead: the alternative is asking the
        user to tap twenty times, and twenty round-trips, to reach the same
        state. This is one request and two queries however large the pile.

        ### What it takes

        Exactly the population the section's count described, so the number on
        the chip is the number of rows that move:

        | included | excluded |
        |---|---|
        | Informational rows (things that merely happened) | Open asks — the **Needs you** zone, whatever subject they share |
        | Read rows as well as unread — a read pile is still a pile | Snoozed rows: the user parked them, and they were never counted |
        | | Expired rows, and rows already archived |

        It **archives**, so it is reversible: dismissed rows stay findable under
        `GET /notifications?folder=archived`, and
        `PATCH /notifications/{id}/unarchive` puts one back.

        Idempotent — dismissing an already-cleared subject is a `200` with
        `count: 0`, not an error.

        ### Choosing a subject

        `subject` is **required**. Over HTTP an omitted field is far more likely
        to be a client bug than an instruction to clear the whole zone, so
        clearing everything takes the literal word `all`.

        Values are the subject keys the notification-category registry derives —
        the same ones `/notifications/home` emits and `?filter=` accepts
        (`wikis`, `system_announcements`, `kind:dm`, …). Spelling of a KEY is
        case- and separator-insensitive, so `news_feed_notifications`,
        `news-feed-notifications` and `News Feed Notifications` are one value.
        Display **labels** are not accepted — the label of
        `news_feed_notifications` is "News Feed", and `subject=News Feed` is a
        `400` whose message names the real key. Send the `key`, not the `label`.
        An unrecognised key is a `400`, never an empty success: resolving it to
        zero rows would report "dismissed 0" for a subject the user can plainly
        see twelve of.

        ### Repainting the screen

        The response carries the count and nothing else. Refetch
        `GET /notifications/home` when you want the server's numbers back;
        recomputing both zones on every dismiss would charge every caller for a
        payload most of them discard, since a client that just cleared a section
        already knows to drop it. The bell needs no second call either —
        `unread_notification_count` rides along on this response as on every
        other.
      security:
      - BearerAuth: []
      parameters:
      - name: subject
        in: query
        required: false
        description: 'The subject to dismiss. May be sent as a query parameter or
          in the request body; the body is preferred. See `subject` in the request
          body schema.

          '
        schema:
          type: string
        example: system_announcements
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required:
              - subject
              properties:
                subject:
                  type: string
                  description: 'An activity subject key from `GET /notifications/home`
                    (`activity.sections[].key`), or `all` to dismiss the whole Activity
                    zone. Case- and separator-insensitive.

                    '
                  example: system_announcements
      responses:
        '200':
          description: 'The subsection was dismissed. `count` is how many notifications
            were archived, and is 0 when there was nothing left to clear.

            '
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - subject
                - all
                - count
                properties:
                  success:
                    type: boolean
                    example: true
                  subject:
                    type: string
                    description: 'The canonical subject key that was dismissed, or
                      `all`. Echoed so a client that sent a non-canonical spelling
                      of the key ("News-Feed-Notifications", "ALL") can match the
                      answer to the section it cleared.

                      '
                    example: system_announcements
                  all:
                    type: boolean
                    description: 'True when the whole Activity zone was cleared rather
                      than one subsection. Stated rather than left to be inferred
                      from `subject`, because an undo prompt or an analytics event
                      needs to know which of the two happened.

                      '
                    example: false
                  count:
                    type: integer
                    description: Notifications archived by this call.
                    example: 12
        '400':
          description: "`subject_required` when `subject` is missing or blank, or
            `invalid_subject` when it names no known subject. The message names a
            value that would have worked.\n"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/notifications/{id}/mark_as_read":
    patch:
      tags:
      - Notifications
      summary: Mark a notification as read
      description: |
        Marks a single notification as read. Sets the `read` flag to `true`
        and records the `read_at` timestamp.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification marked as read
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/{id}/mark_as_unread":
    patch:
      tags:
      - Notifications
      summary: Mark a notification as unread
      description: |
        Marks a single notification as unread. Sets the `read` flag to `false`
        and clears the `read_at` timestamp.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification marked as unread
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/mark_all_as_read":
    patch:
      tags:
      - Notifications
      summary: Mark all notifications as read
      description: |
        Marks multiple notifications as read in bulk. If `ids` is provided in the
        request body, only those specific notifications are marked. Otherwise, all
        unread notifications for the current user are marked as read.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                  description: Optional list of specific notification IDs to mark
                    as read. If omitted, marks all unread notifications.
                  example:
                  - 1
                  - 2
                  - 3
      responses:
        '200':
          description: Notifications marked as read
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - count
                properties:
                  success:
                    type: boolean
                    example: true
                  count:
                    type: integer
                    description: Number of notifications marked as read
                    example: 5
        '401':
          description: Unauthorized
  "/notifications/{id}/archive":
    patch:
      tags:
      - Notifications
      summary: Archive a notification
      description: |
        Archives a single notification. Archived notifications are excluded from
        the default notification list.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification archived
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/{id}/unarchive":
    patch:
      tags:
      - Notifications
      summary: Unarchive a notification
      description: 'Restores a previously archived notification back to the active
        list.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification unarchived
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/archive_all":
    patch:
      tags:
      - Notifications
      summary: Archive multiple notifications
      description: |
        Archives multiple notifications in bulk. If `ids` is provided, only those
        specific notifications are archived. Otherwise, all notifications for the
        current user are archived.

        Pass `unarchive: true` to unarchive instead of archive.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                  description: Optional list of specific notification IDs to archive
                  example:
                  - 1
                  - 2
                  - 3
                unarchive:
                  type: boolean
                  description: If true, unarchives the notifications instead
                  default: false
      responses:
        '200':
          description: Notifications archived/unarchived
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
  "/broadcasts":
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List the caller's received broadcasts (Dashboard inbox)
      description: |
        Returns the SAME broadcasts and ordering the web Broadcast app shows in
        the Dashboard > "Broadcasts" section: the signed-in user's personal
        inbox of **published** broadcasts they received (snapshot membership) or
        authored — never the full business broadcast list.

        Ordering matches the web priority sort: critical + unacknowledged first,
        then critical + unread, then critical, then unacknowledged, then unread,
        then read; ties break by `published_at DESC`, then `id DESC`.

        Supports the same four Dashboard subfilters via `filter`. Delegates to
        `BroadcastQueries#my_broadcasts` so the API can't drift from the web.
      parameters:
      - name: filter
        in: query
        description: |
          Item subfilter. Default `all`.
            * `all`         — every received published broadcast
            * `unread`      — broadcasts the caller has not viewed
            * `critical`    — `is_critical = true`
            * `acknowledge` — EVERY acknowledgment-required broadcast
                              (`require_acknowledgment = true`), regardless of
                              whether the caller has acknowledged. (This differs
                              from the web "Acknowledge" chip, which shows only
                              pending acknowledgments.)
        required: false
        schema:
          type: string
          enum:
          - all
          - unread
          - critical
          - acknowledge
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Broadcasts listed (mirrors GET /inspections — collection +
            meta)
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcasts
                - pending_approvals
                - pending_acknowledgements
                - can_manage
                - meta
                properties:
                  broadcasts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BroadcastSummary"
                  pending_acknowledgements:
                    type: array
                    maxItems: 25
                    description: |
                      Acknowledgment-required broadcasts the caller has NOT
                      acknowledged yet ({id, title} only), newest published first,
                      capped at 25 (a preview, not the whole set).
                      Independent of pagination/?filter=. Keyed off the
                      not-acknowledged flag (distinct from meta.segment_counts.
                      acknowledge, which is keyed off not-read).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                  pending_approvals:
                    type: array
                    description: |
                      Broadcasts awaiting the CALLER's approval (pending Comms Hub
                      approval requests whose current step targets the caller's
                      role; admins see all). Same item shape as `broadcasts`.
                      Independent of pagination/?filter=.
                    items:
                      "$ref": "#/components/schemas/BroadcastSummary"
                  can_manage:
                    type: boolean
                    description: |
                      Root-level capability flag (not per item): whether the caller
                      can manage broadcasts generally — admin/above or the
                      broadcasts manage/edit permission.
                  meta:
                    "$ref": "#/components/schemas/BroadcastListMeta"
        '401':
          description: Authentication required
        '403':
          description: The Broadcasts & Alerts app is not enabled for this business
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Create and send a broadcast immediately (composers)
      description: |
        Mirrors the web composer's **Send Now** — there is no draft on mobile.
        The broadcast is created and **published immediately** (status
        `published`, fan-out begins). Requires the broadcasts **create**
        permission (admins/super-admins have it; managers/members when granted).

        **Approval:** if an enforced approval workflow governs broadcasts, a
        non-admin's send is routed through approval instead of publishing — the
        broadcast is submitted for review and the response returns
        `status: "pending_approval"`. Admins override approval and publish
        directly (same as web).

        At least one recipient target MUST be supplied (`audience_id`,
        `notification_recipient_group_ids`, `extra_user_ids`, or
        `audience_criteria`); a request with no target — or one that resolves to
        nobody in this business — returns `422 no_recipients` and nothing is
        persisted (no lingering draft).

        **Send later:** pass `scheduled_at` and the broadcast is left in status
        `scheduled` for the tick job instead of fanning out now — the response
        carries `status: "scheduled"`. The approval and moderation gates run
        FIRST, so a broadcast that would be held for review cannot slip out on a
        timer. A past or unparseable time is `422 invalid_scheduled_at`, never a
        silent immediate send.

        **Title is optional.** The unified Communications composer has no Title
        field, so a blank or absent `title` is derived from the first line of
        `description` (capped at a headline length) rather than rejected. A
        blank `description` is still a validation error.

        **Break-room screens:** pass `publish_to_signage` (optionally narrowed
        by `signage_location_ids`) to also put the broadcast into the Digital
        Signage rotation. Screens PULL — nothing is delivered by this request,
        and each screen's own content rules still decide whether it shows. If
        the tenant has no active screen the broadcast still sends on its other
        channels and the 201 carries a `warnings` entry saying nothing was
        queued, rather than failing the send.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - broadcast
              properties:
                broadcast:
                  type: object
                  required:
                  - description
                  properties:
                    title:
                      type: string
                      description: |
                        Optional. The subject line every channel renders (push,
                        email, SMS) and the headline of the broadcast's News
                        Feed record. When blank or absent it is DERIVED from the
                        first line of `description`, so a composer with no Title
                        field never has to send the message twice.
                    description:
                      type: string
                      description: The message body. Required.
                    is_critical:
                      type: boolean
                    require_acknowledgment:
                      type: boolean
                    allow_comments:
                      type: boolean
                    allow_reactions:
                      type: boolean
                    auto_widen_channels:
                      type: boolean
                      description: |
                        Automatically widen the delivery channel while an
                        acknowledgement is still outstanding: each reminder adds
                        the next channel (in-app + email, then +SMS, then
                        +voice). Only meaningful together with
                        `require_acknowledgment` — the reminder job that drives
                        it runs for required-reading broadcasts only. Honors
                        quiet hours, channel rules and notification preferences.
                        Omit the key to leave it at the tenant default (off).
                    delivery_mode:
                      type: string
                      enum:
                      - immediate
                      - on_shift_only
                      - next_shift_start
                      default: immediate
                      description: |
                        The shift-aware delivery window:

                          * `immediate` — send to everyone now (default).
                          * `on_shift_only` — deliver only to recipients who are
                            currently on shift; off-shift recipients are
                            suppressed.
                          * `next_shift_start` — hold off-shift recipients and
                            deliver at the start of their next shift.

                        Critical alerts ignore this and always break through
                        immediately. An unrecognized value degrades to
                        `immediate` rather than holding the send back.
                    scheduled_at:
                      type: string
                      format: date-time
                      description: |
                        Send the broadcast at a future time instead of now. Parsed
                        in the CALLER's time zone, so a bare
                        `"2026-08-12 09:00"` means 9am where the author is. The
                        record is left in status `scheduled` and published by
                        `ScheduledBroadcastPublishJob`. Must be in the future —
                        a past or unparseable value returns
                        `422 invalid_scheduled_at`.
                    channels:
                      type: array
                      description: |
                        Delivery channels to ENABLE (email / sms / voice). Any not
                        listed are disabled. `in_app` always delivers and `push`
                        stays on regardless. Omit the key entirely to leave all
                        channels at their default (on).
                      items:
                        type: string
                        enum:
                        - email
                        - sms
                        - voice
                    publish_to_signage:
                      type: boolean
                      description: |
                        The composer's **Break-room screens** channel — mark this
                        broadcast for the Digital Signage rotation, the channel
                        that reaches frontline staff with no work phone and no
                        work email.

                        It is a REQUEST, not a delivery. Screens PULL: each one
                        shows the broadcast on its next refresh, and only where
                        its signage admin has "Communications posts" switched on.
                        Only PUBLISHED broadcasts are picked up, and only for 14
                        days, so a draft or a held send never reaches a wall.

                        Screens are token-authenticated PUBLIC web pages —
                        anyone standing in the room can read them. Only set this
                        for something you would put on a wall.

                        Deliberately separate from `channels`: that array is the
                        per-USER delivery map, and a screen is a place, not a
                        recipient.

                        Omit the key to leave the selection unchanged. On PATCH,
                        send `false` to take a broadcast back off the screens.
                    signage_location_ids:
                      type: array
                      description: |
                        Narrow `publish_to_signage` to specific sites. Omit or
                        send an empty array to show on every screen. Ids outside
                        the caller's business are dropped, and the key is ignored
                        entirely unless `publish_to_signage` is on.
                      items:
                        type: integer
                    audience_id:
                      type: integer
                      nullable: true
                      description: A saved CommsHub audience to send to.
                    extra_user_ids:
                      type: array
                      description: Specific user ids to send to.
                      items:
                        type: integer
                    audience_criteria:
                      type: array
                      description: |
                        Attribute-based audience filters, each a typed hash — e.g.
                        `{ "type": "role", "roles": ["member"] }`,
                        `{ "type": "job_title", "titles": ["Area Manager"] }`,
                        `{ "type": "department", "ids": [1,2] }`,
                        `{ "type": "location", "ids": [3] }`.
                      items:
                        type: object
                        additionalProperties: true
                    media_signed_ids:
                      type: array
                      description: |
                        Attachments. Pre-upload each file via
                        `POST /rails/active_storage/direct_uploads` (standard
                        ActiveStorage direct upload) and pass the resulting blob
                        `signed_id`s here. They are attached to the broadcast's
                        `media_files` (Drive) — the same attachments the web
                        composer and the show API expose. Validated server-side
                        against the broadcast media rules (image/pdf/video, per-file
                        size cap, max 10 files); any invalid reference fails the
                        whole create with 422 and nothing is persisted.
                      items:
                        type: string
                notification_recipient_group_ids:
                  type: array
                  description: Notification recipient group ids to send to (top-level,
                    not under `broadcast`).
                  items:
                    type: integer
      responses:
        '201':
          description: |
            Broadcast created. Normally **published** immediately
            (`broadcast.status: "published"`). Two other outcomes are also 201:

              * **submitted for approval** — an enforced approval workflow
                governs broadcasts and the caller is NOT an admin (admins
                override), or the request set `submit_for_approval`. The
                response carries `status: "pending_approval"`; an approver sends
                it later via `POST /approvals/{id}/approve`.

                `broadcast.status` is `"draft"` for a send-now composition, and
                `"scheduled"` when the request also carried `scheduled_at` — the
                author's send time is KEPT through the review so the approver
                can see when it goes out and it still sends at that time once
                approved. It cannot slip out early: the tick job refuses any
                broadcast whose approval request is still pending.
              * **scheduled** — `scheduled_at` was supplied. The response carries
                `status: "scheduled"` and `broadcast.status: "scheduled"`; the
                tick job publishes it at the requested time.
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  channels:
                    type: array
                    description: The channels the broadcast will fan out on (in_app
                      always; email/sms/voice/push unless toggled off).
                    items:
                      type: string
                  attachments:
                    type: array
                    description: The attachments that were attached to the broadcast
                      (from media_signed_ids).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                  status:
                    type: string
                    enum:
                    - pending_approval
                    description: Present only when the send was routed through approval
                      instead of publishing.
                  message:
                    type: string
                  warnings:
                    type: array
                    description: |
                      Non-fatal notices about parts of the request that could not
                      be honoured — the broadcast still sent. Present only when
                      non-empty. Today this carries the outcome of a
                      `publish_to_signage` request: the confirmation naming how
                      many screens it was queued for, or the reason nothing was
                      (no active screen registered, or none at the sites picked).
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: No permission to create broadcasts, or app not enabled
        '422':
          description: |
            Validation failed (`validation_failed`); no recipient target supplied
            or it resolved to nobody (`no_recipients`); or approval is required but
            the request could not be submitted (`approval_required`).
  "/broadcasts/templates":
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List broadcast templates for the composer's "Start from a template"
        picker
      description: |
        Powers the broadcast composer's "Start from a template" picker (opened
        from the broadcast list `+`). Returns the two groups the web Broadcast app
        offers:
          * `your_templates`    — this business's saved broadcast templates (ordered)
          * `gallery_templates` — the platform-curated "Browse Library" templates
                                  (system templates shared across businesses), so an
                                  author can start from a pre-built composition.
        Each entry carries the fields the new-broadcast form pre-fills from a
        template (title ← `name`, description ← `body`, the critical / ack /
        comment / reaction flags, and the saved audience + recipient groups).
        Requires the broadcasts **create** permission — the same gate as
        `POST /broadcasts` (admins/super-admins always; managers/members when
        granted). Anyone who can create a broadcast can fetch its templates.
      responses:
        '200':
          description: Templates listed
          content:
            application/json:
              schema:
                type: object
                required:
                - your_templates
                - gallery_templates
                - meta
                properties:
                  your_templates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - body
                      - system_template
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          description: Template name — pre-fills the new broadcast's
                            title.
                        description:
                          type: string
                          nullable: true
                        body:
                          type: string
                          description: Template body — pre-fills the new broadcast's
                            description/body.
                        is_critical:
                          type: boolean
                        require_acknowledgment:
                          type: boolean
                        allow_comments:
                          type: boolean
                        allow_reactions:
                          type: boolean
                        audience_id:
                          type: integer
                          nullable: true
                          description: Saved CommsHub audience to pre-fill the recipient
                            picker, if any.
                        notification_recipient_group_ids:
                          type: array
                          items:
                            type: integer
                          description: Saved recipient group ids to pre-fill the recipient
                            picker.
                        system_template:
                          type: boolean
                          description: True for a platform-curated gallery template
                            (business_id is null).
                        metadata:
                          type: object
                          description: |
                            Saved composition extras the composer pre-fills from —
                            e.g. `channel_rules`, `audience_criteria`, `extra_user_ids`.
                          additionalProperties: true
                        created_at:
                          type: string
                          format: date-time
                          nullable: true
                  gallery_templates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - body
                      - system_template
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          description: Template name — pre-fills the new broadcast's
                            title.
                        description:
                          type: string
                          nullable: true
                        body:
                          type: string
                          description: Template body — pre-fills the new broadcast's
                            description/body.
                        is_critical:
                          type: boolean
                        require_acknowledgment:
                          type: boolean
                        allow_comments:
                          type: boolean
                        allow_reactions:
                          type: boolean
                        audience_id:
                          type: integer
                          nullable: true
                          description: Saved CommsHub audience to pre-fill the recipient
                            picker, if any.
                        notification_recipient_group_ids:
                          type: array
                          items:
                            type: integer
                          description: Saved recipient group ids to pre-fill the recipient
                            picker.
                        system_template:
                          type: boolean
                          description: True for a platform-curated gallery template
                            (business_id is null).
                        metadata:
                          type: object
                          description: |
                            Saved composition extras the composer pre-fills from —
                            e.g. `channel_rules`, `audience_criteria`, `extra_user_ids`.
                          additionalProperties: true
                        created_at:
                          type: string
                          format: date-time
                          nullable: true
                  meta:
                    type: object
                    required:
                    - your_templates_count
                    - gallery_templates_count
                    properties:
                      your_templates_count:
                        type: integer
                      gallery_templates_count:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: No permission to create broadcasts, or the app is not enabled
  "/broadcasts/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Get a broadcast
      description: |
        Returns the broadcast detail plus `meta.segment_counts` — the same
        { all, critical, acknowledge } NOT-READ inbox counts the list endpoint
        exposes, so the detail screen can keep the inbox badges current without a
        separate list request. The counts reflect the inbox state AFTER this
        broadcast is marked read by the fetch.
      responses:
        '200':
          description: Broadcast found
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                - meta
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  meta:
                    type: object
                    required:
                    - segment_counts
                    properties:
                      segment_counts:
                        type: object
                        description: |
                          NOT-READ inbox counts for the caller (mirrors the list
                          endpoint). `all` is the unread total — no separate
                          `unread` key.
                        required:
                        - all
                        - critical
                        - acknowledge
                        properties:
                          all:
                            type: integer
                          critical:
                            type: integer
                          acknowledge:
                            type: integer
        '401':
          description: Authentication required
        '403':
          description: Not permitted to view this broadcast (not a recipient, author,
            or manager/admin)
        '404':
          description: Broadcast not found
    patch:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Update a draft broadcast (admin only)
      description: |
        Only broadcasts still in `draft` status can be edited via the API.

        A PARTIAL edit: send only the attributes you are changing. Omitted keys
        are left alone — including `title`, which is no longer re-derived from a
        `description` you did send.

        `publish_to_signage` follows PATCH semantics: omit it to leave the
        break-room screen selection alone, send `false` to take the broadcast
        off the screens. A selection that could not be honoured comes back in a
        `warnings` array rather than failing the edit.

        Recipients (`extra_user_ids`) and the `channels` mix are set at create
        time and cannot be edited; sending either changes nothing and is named
        in `warnings`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - broadcast
              properties:
                broadcast:
                  "$ref": "#/components/schemas/BroadcastInput"
      responses:
        '200':
          description: Broadcast updated
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  warnings:
                    type: array
                    description: Non-fatal notices — present only when non-empty.
                      Carries the outcome of a `publish_to_signage` request (the confirmation
                      naming how many screens it was queued for, or the reason nothing
                      was), plus a note naming any create-only key (`extra_user_ids`,
                      `channels`) that was sent and therefore ignored.
                    items:
                      type: string
        '403':
          description: Admin access required, or broadcast is not a draft
        '404':
          description: Broadcast not found
        '422':
          description: Validation failed
  "/broadcasts/{id}/publish":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Publish a draft broadcast (admin only)
      description: |
        Runs the model's `publish!` so the API matches the web path: recipient
        snapshot, variant assignment, and the notification fan-out all run.
      responses:
        '200':
          description: Broadcast published
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
        '403':
          description: Admin access required, or broadcast is already published
        '404':
          description: Broadcast not found
        '422':
          description: Could not publish broadcast
  "/broadcasts/{id}/acknowledge":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Acknowledge a broadcast (recipient)
      description: Records the caller's acknowledgment of a published broadcast that
        requires it.
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                required:
                - acknowledged
                - broadcast_id
                properties:
                  acknowledged:
                    type: boolean
                  broadcast_id:
                    type: integer
        '401':
          description: Authentication required
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast is not published or does not require acknowledgment
  "/broadcasts/{id}/viewers":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List recipients who have viewed the broadcast (read receipts)
      description: |
        Read-receipt roster powering the "Viewed by N of M" flyout on the
        Broadcast detail screen. Returns the recipients who have **viewed** the
        broadcast, ordered most-recently-viewed first and paginated, plus the
        total-recipients and unique-view counts in `meta`.

        Accessible to anyone who can see the broadcast itself: a **recipient**
        (in the recipient list), the **author**, or a **manager/admin**. Drafts
        have no recipient list, so they are limited to the author and
        managers/admins. Anyone else gets `403`.
      parameters:
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Viewers listed (most-recently-viewed first)
          content:
            application/json:
              schema:
                type: object
                required:
                - viewers
                - meta
                properties:
                  viewers:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - viewed_at
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Viewer's job title (row subtitle).
                        location:
                          type: string
                          nullable: true
                          description: Viewer's office location (row subtitle).
                        viewed_at:
                          type: string
                          format: date-time
                          nullable: true
                  meta:
                    type: object
                    required:
                    - total_recipients_count
                    - unique_view_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_recipients_count:
                        type: integer
                        description: Total recipients the broadcast reached (snapshot
                          size — the "M").
                      unique_view_count:
                        type: integer
                        description: Distinct recipients who have viewed it (the "N").
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller is not a recipient, the author, or a manager/admin for
            this broadcast
        '404':
          description: Broadcast not found
  "/broadcasts/{id}/acknowledgements":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List recipients by acknowledgement state (Acknowledgement tracking)
      description: |
        Acknowledgement-tracking roster powering the "Acknowledgement tracking"
        flyout (Acknowledged / Pending tabs) on the Broadcast detail screen.
        Returns the broadcast's recipients filtered by whether they have
        acknowledged it, in the same order as the web "View Recipients" page
        (user id), paginated, plus the total / acknowledged / not-acknowledged
        counts in `meta`.

        Only meaningful for `require_acknowledgment` broadcasts (others return
        `422`). Restricted to callers who can see "View stats" — a **published**
        broadcast they can **manage** (admin/manager, or the author when a
        manager+). Anyone else gets `403`.
      parameters:
      - name: type
        in: query
        description: |
          Which roster to return. Default `acked`.
            * `acked`     — recipients who HAVE acknowledged (each row carries
                            the `acknowledged_at` time).
            * `not_acked` — recipients who have NOT acknowledged
                            (`acknowledged_at` is null).
        required: false
        schema:
          type: string
          enum:
          - acked
          - not_acked
          default: acked
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed (filtered by acknowledgement state, user-id
            order)
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When this recipient acknowledged (null on the
                            not_acked roster).
                  meta:
                    type: object
                    required:
                    - type
                    - total_recipients_count
                    - acked_count
                    - not_acked_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      type:
                        type: string
                        description: The resolved roster type (acked | not_acked).
                      total_recipients_count:
                        type: integer
                        description: Total recipients the broadcast reached.
                      acked_count:
                        type: integer
                        description: Recipients who have acknowledged.
                      not_acked_count:
                        type: integer
                        description: Recipients who have not acknowledged.
                      total_count:
                        type: integer
                        description: Items matching the active type (this list's size).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller cannot see "View stats" (not published, or not a manager/admin/author)
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast does not require acknowledgment
  "/broadcasts/{id}/comments":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List a broadcast's comments (paginated)
      description: |
        Full, paginated comment feed for the broadcast — the same top-level
        comments the detail screen renders. Each item has the IDENTICAL shape to
        the `recent_comments` items in GET /broadcasts/{id} ({ id, body,
        created_at, author, attachments }); the only difference is this returns
        the WHOLE feed page-by-page instead of just the latest 5. Top-level
        comments only (replies excluded), newest first (`created_at DESC`).

        Accessible to anyone who can see the broadcast itself: a **recipient**,
        the **author**, or a **manager/admin** (same gate as show). Anyone else
        gets `403`.
      parameters:
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Comments listed (newest first)
          content:
            application/json:
              schema:
                type: object
                required:
                - comments
                - meta
                properties:
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BroadcastComment"
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller is not a recipient, the author, or a manager/admin for
            this broadcast
        '404':
          description: Broadcast not found
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Add a comment to a broadcast
      description: |
        Creates a comment, mirroring the web BroadcastCommentsController#create
        (including content moderation — a held comment is hidden until a moderator
        approves it and the response carries `held: true`).

        Accessible to anyone who can see the broadcast (recipient / author /
        manager / admin) when commenting is enabled on it (`allow_comments`).
        Returns the created comment in the canonical shape, including `can_edit`
        / `can_delete` for the caller.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: object
                  required:
                  - content
                  properties:
                    content:
                      type: string
                      description: The comment body.
                    parent_id:
                      type: integer
                      nullable: true
                      description: Set to reply to an existing comment; omit/null
                        for a top-level comment.
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BroadcastCommentWriteResult"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot view this broadcast, or commenting is disabled
            (`comments_disabled`)
        '404':
          description: Broadcast not found
        '422':
          description: Validation failed (e.g. blank content)
  "/broadcasts/{id}/comments/{comment_id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    - name: comment_id
      in: path
      required: true
      description: Comment ID
      schema:
        type: integer
    patch:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Edit a comment (author only)
      description: |
        Updates a comment's content. Permission matches the `can_edit` flag: the
        comment's **author only** (an admin editing someone else's comment gets
        `403`). Re-runs content moderation like the web edit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: object
                  required:
                  - content
                  properties:
                    content:
                      type: string
                      description: The comment body.
                    parent_id:
                      type: integer
                      nullable: true
                      description: Set to reply to an existing comment; omit/null
                        for a top-level comment.
      responses:
        '200':
          description: Comment updated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BroadcastCommentWriteResult"
        '401':
          description: Authentication required
        '403':
          description: Caller is not the comment's author
        '404':
          description: Broadcast or comment not found
        '422':
          description: Validation failed (e.g. blank content)
    delete:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Delete a comment (author or admin/above)
      description: |
        Soft-deletes a comment. Permission matches the `can_delete` flag and the
        web delete visibility: the comment's **author OR an admin/above** member.
      responses:
        '200':
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - comment_id
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  comment_id:
                    type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller is neither the comment's author nor an admin/above
        '404':
          description: Broadcast or comment not found
  "/broadcasts/{id}/resend":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Resend a broadcast to pending recipients ("Resend Now")
      description: |
        Like the web **Resend Now** control on the View-stats page
        (`BroadcastsController#resend_to_pending`). Re-delivers the broadcast to
        every recipient who has **not acknowledged** yet, reusing the model's
        per-channel delivery (so delivery receipts and each user's notification
        preferences are honored).

        The resend goes over the channels the broadcast was **configured with at
        creation** (the author's per-broadcast channel toggles), NOT channels
        supplied in the request — there is no request body, and any `channels`
        param is ignored. The channels actually used are echoed back in the
        response `channels` array.

        Authorization mirrors the web: the caller must be able to **manage** the
        broadcast — an admin (or above), a holder of the broadcasts `manage`/`edit`
        permission, or the author when a manager+. Otherwise `403`.

        Only valid for a **published**, **acknowledgment-required** broadcast.
      responses:
        '200':
          description: Resent to pending recipients
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast_id
                - channels
                - resent_count
                - message
                properties:
                  broadcast_id:
                    type: integer
                  channels:
                    type: array
                    description: The creation-time channels the broadcast was resent
                      over (subset of `email` / `sms` / `voice`).
                    items:
                      type: string
                  resent_count:
                    type: integer
                    description: Number of pending (not-yet-acknowledged) recipients
                      the broadcast was resent to.
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Caller cannot manage this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: Not a published, acknowledgment-required broadcast, or no resendable
            channel was enabled at creation
  "/broadcasts/{id}/remind":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Send an acknowledgment reminder to a single recipient
      description: |
        Sends an acknowledgment reminder to ONE recipient — the same reminder the
        web sends automatically (`BroadcastAcknowledgmentReminderJob#send_reminder`):
        an in-app Notification plus the reminder email, bumping `reminder_count`
        and `last_reminder_at` on the recipient's status row.

        Authorization mirrors the web reminder/resend controls: the caller must be
        able to **manage** the broadcast (admin/above, broadcasts `manage`/`edit`
        permission, or the author when a manager+). Otherwise `403`.

        Only valid for a **published**, **acknowledgment-required** broadcast and a
        recipient who has **not acknowledged** yet.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                  description: The recipient to remind.
      responses:
        '200':
          description: Reminder sent
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast_id
                - user_id
                - reminder_count
                - message
                properties:
                  broadcast_id:
                    type: integer
                  user_id:
                    type: integer
                  reminder_count:
                    type: integer
                    description: The recipient's cumulative reminder count after this
                      send.
                  last_reminder_at:
                    type: string
                    format: date-time
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Caller cannot manage this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: |
            Not a published / acknowledgment-required broadcast (`cannot_remind`),
            `user_id` missing or not in the business (`user_not_found`), the user is
            not a recipient (`not_a_recipient`), or the user already acknowledged
            (`already_acknowledged`).
  "/broadcasts/{id}/archive":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Archive a published broadcast
      description: |
        Archives a **published** broadcast (status → `archived`), mirroring the
        web "Archive" action.

        Authorization mirrors the web archive: the caller must be able to
        **manage** the broadcast (admin/above, broadcasts `manage`/`edit`
        permission, or the author when a manager+). Otherwise `403`.

        Only **published** broadcasts can be archived — drafts and scheduled
        broadcasts return `422` (they are deleted, not archived).
      responses:
        '200':
          description: Broadcast archived (returns the updated broadcast)
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot manage this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast is not published (`not_published`), or archiving
            failed (`archive_failed`)
  "/broadcasts/{broadcast_id}/reactions/toggle":
    parameters:
    - name: broadcast_id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      summary: Toggle (add/remove) an emoji reaction on a broadcast
      security:
      - BearerAuth: []
      description: "Adds or removes the caller's emoji reaction, mirroring the web\n`BroadcastReactionsController#toggle`
        (a single TOGGLE per emoji via\n`Platform::Reactable#toggle_reaction`): re-sending
        the same emoji removes\nthe caller's reaction, otherwise it adds one. `reacted`
        in the response is\n`true` when the reaction is now ON (added), `false` when
        removed.\n\nAccessible to anyone who can see the broadcast (recipient / author
        /\nmanager / admin) when reactions are enabled on it (`allow_reactions`).\nThe
        emoji must be in the broadcast's allowed set\n(`\U0001F44D ❤️ \U0001F604 \U0001F622
        \U0001F62E \U0001F389 \U0001F44F ✅`).\n"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: The emoji to toggle. Must be in the broadcast's allowed
                    reaction set.
      responses:
        '200':
          description: Reaction toggled
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast_id
                - emoji
                - reacted
                - reactions
                - my_reactions
                properties:
                  broadcast_id:
                    type: integer
                  emoji:
                    type: string
                  reacted:
                    type: boolean
                    description: True when the reaction is now ON (added); false when
                      it was removed.
                  reactions:
                    type: array
                    description: Updated reaction tallies for the broadcast.
                    items:
                      type: object
                      required:
                      - emoji
                      - count
                      properties:
                        emoji:
                          type: string
                        count:
                          type: integer
                  my_reactions:
                    type: array
                    description: The caller's current emoji reactions on this broadcast.
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: Caller cannot view this broadcast, or reactions are disabled
            (`reactions_disabled`)
        '404':
          description: Broadcast not found
        '422':
          description: Emoji not in the broadcast's allowed reaction set (`invalid_emoji`)
  "/approvals/{id}/approve":
    parameters:
    - name: id
      in: path
      required: true
      description: Comms Hub approval request id (from a source's `approval.request_id`).
      schema:
        type: integer
    post:
      tags:
      - Approvals
      security:
      - BearerAuth: []
      summary: Approve a pending approval request (broadcast or alert)
      description: |
        Records the caller's **approve** decision on a pending Comms Hub approval
        request — for a broadcast OR an alert — mirroring
        `Apps::CommsHub::ApprovalsController#approve`. Appends an entry to the
        request's audit trail and advances the workflow: when the approved step
        is the last one the status becomes `approved`; otherwise it moves to the
        next step (still `pending`).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  description: Optional reviewer note, stored on the audit entry.
      responses:
        '200':
          description: Decision recorded
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ApprovalDecision"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot act on the current step (`forbidden`), or app
            not enabled (`app_disabled`)
        '404':
          description: No pending approval request with that id for this business
            (`not_found`)
        '422':
          description: Decision could not be recorded (`approval_decision_failed`)
  "/approvals/{id}/reject":
    parameters:
    - name: id
      in: path
      required: true
      description: Comms Hub approval request id (from a source's `approval.request_id`).
      schema:
        type: integer
    post:
      tags:
      - Approvals
      security:
      - BearerAuth: []
      summary: Reject a pending approval request (broadcast or alert)
      description: |
        Records the caller's **reject** decision on a pending Comms Hub approval
        request — for a broadcast OR an alert — mirroring
        `Apps::CommsHub::ApprovalsController#reject`. Appends an entry to the
        audit trail and closes the request (status → `rejected`).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  description: Optional reviewer note, stored on the audit entry.
      responses:
        '200':
          description: Decision recorded
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ApprovalDecision"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot act on the current step (`forbidden`), or app
            not enabled (`app_disabled`)
        '404':
          description: No pending approval request with that id for this business
            (`not_found`)
        '422':
          description: Decision could not be recorded (`approval_decision_failed`)
  "/audience_options":
    get:
      tags:
      - Audience Options
      security:
      - BearerAuth: []
      summary: List audience-picker options (departments / locations / job titles
        / roles / groups)
      description: |
        Powers "Add to audience" in the mobile broadcast and alert composers. The
        `type` param selects which attribute to list; each item is
        `{ id, name, user_count }`:
          * `departments` / `locations` / `groups` → integer `id`
          * `roles` (admin / manager / member) / `job_titles` → string `id`
            (the role key / the title text)

        `user_count` is the number of ACTIVE business users the entity resolves
        to, computed with the same logic as the audience resolver so the picker
        count matches the eventual reach. For parametric recipient groups whose
        membership varies per send, `user_count` is `null`.

        Supports `q` (case-insensitive name/title search) and pagination. Mirrors
        the web BroadcastRecipientParametersController sources (e.g. roles are the
        canonical admin/manager/member keys — super_admin is excluded).
      parameters:
      - name: type
        in: query
        required: true
        description: Which attribute list to return.
        schema:
          type: string
          enum:
          - departments
          - locations
          - job_titles
          - roles
          - groups
      - name: q
        in: query
        required: false
        description: Case-insensitive search over the entity name / title.
        schema:
          type: string
      - name: page
        in: query
        required: false
        description: Page number (default 1).
        schema:
          type: integer
      - name: per_page
        in: query
        required: false
        description: Items per page (default 25, max 100).
        schema:
          type: integer
      responses:
        '200':
          description: Options listed for the requested type
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      properties:
                        id:
                          description: Integer id (departments/locations/groups) or
                            string key (roles/job_titles).
                          oneOf:
                          - type: integer
                          - type: string
                        name:
                          type: string
                        user_count:
                          type: integer
                          nullable: true
                          description: Active users the entity resolves to; null for
                            parametric groups (membership varies).
                  meta:
                    type: object
                    required:
                    - type
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      type:
                        type: string
                        enum:
                        - departments
                        - locations
                        - job_titles
                        - roles
                        - groups
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller can't compose (not a manager/admin or broadcasts creator),
            or the app is not enabled
        '422':
          description: Missing or unknown `type` (`invalid_type`)
  "/frontline_execution/my_day":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The worker's filtered, paginated My Day list
      description: |
        Returns one page of the logged-in worker's My Day list for the selected
        filter, plus the exact count of all four filters on every call so a tab
        bar renders from a single request.

        This is the same ranked list the web My Day shows, so `assigned` includes
        both the obligations the worker holds AND the must-confirm acknowledgement
        "posts" they owe (a post has no single assignee — everyone in its audience
        must confirm it).

        **`filter`** selects one tab (all four are slices of that one list):

        | filter     | contains                                                        |
        |------------|-----------------------------------------------------------------|
        | `assigned` | the worker's whole My Day — held obligations + must-confirm posts (default) |
        | `overdue`  | those that are past due and still open                          |
        | `critical` | those whose campaign is marked critical (posts included)        |
        | `pool`     | the claim pool — unclaimed, role-matched work at the worker's sites |

        **`counts`** carries all four badges on every response (0 included), each
        the exact size of that filter's list — `overdue` and `critical` are
        subsets of `assigned`, so a row can count under more than one.

        Because the list includes posts (resolved in Ruby, not SQL), it is ranked
        and paged in memory over the ranker's bounded output: a page reaches at
        most the ranker's per-lane cap, exactly as the web list is capped.

        Rows are ordered by the same rank the worker sees on the web (overdue →
        HQ priority → soonest due → id), a total order so paging is stable across
        requests. `meta` is the pagination envelope for the SELECTED filter.

        An unrecognised `filter` (a typo, or an array value) is not an error: it
        defaults to `assigned` and is disclosed via `meta.filter_ignored` /
        `meta.filter_note`, and the applied filter is echoed back in `filter`, so
        a client never mistakes one tab's list for another's.

        Claim-pool rows are flagged `claimable: true` with a null `assignee`; a
        client renders a Claim action for them instead of Done/Release.
      parameters:
      - name: filter
        in: query
        required: false
        description: Which tab to return. Defaults to `assigned`.
        schema:
          type: string
          enum:
          - assigned
          - overdue
          - critical
          - pool
          default: assigned
      - "$ref": "#/components/parameters/Page"
      - name: limit
        in: query
        required: false
        description: Rows per page (1–100, default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The selected filter's page, all four counts, and pagination
            meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The filter actually rendered (the normalised `filter`).
                    enum:
                    - assigned
                    - overdue
                    - critical
                    - pool
                    example: assigned
                  counts:
                    type: object
                    description: Exact size of all four filters (0 included). Filters
                      overlap.
                    properties:
                      assigned:
                        type: integer
                        example: 7
                      overdue:
                        type: integer
                        example: 2
                      critical:
                        type: integer
                        example: 1
                      claim_pool:
                        type: integer
                        example: 3
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MyDayListItem"
                  meta:
                    "$ref": "#/components/schemas/MyDayListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            My Day surface is turned off (`surface_disabled`).
  "/frontline_execution/my_day/everything":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The worker's cross-app "Everything" list (My Day)
      description: |
        Returns one ranked, paginated list of everything the logged-in worker
        owes today across apps, plus the exact count of every filter lane so a
        tab bar renders from a single call.

        **`filter`** selects one lane. `all` (the default) is the whole union;
        every other value is that one lane:

        | filter        | contains                                   |
        |---------------|--------------------------------------------|
        | `all`         | the whole deduped, ranked union            |
        | `frontline`   | Frontline Execution obligations            |
        | `inspections` | Inspections assigned to the worker         |
        | `posts`       | must-read messages awaiting acknowledgement|
        | `tasks`       | ordinary Tasks (not minted by Frontline)   |
        | `training`    | assigned/in-progress/overdue training      |
        | `schedule`    | shift offers addressed to the worker       |
        | `approvals`   | requests waiting on the worker's decision  |

        **`counts`** carries EVERY lane on every call (0 included), computed over
        the whole ranked union — so a lane's badge equals what paging that lane
        actually returns. `counts.all` is the union total.

        **`degraded_sources`** names any lane whose owning app failed to load
        (returned in the same product vocabulary as `filter`). A non-empty array
        means the list is INCOMPLETE — a client must say so rather than present a
        silently-short list as the whole day.

        **Bounded by design.** Each lane is capped server-side, so the union is a
        small, fixed size regardless of how much work the tenant has; the list is
        ranked and paged in memory because it spans seven models with no shared
        table or order key. Paging is stable across requests.

        An unrecognised `filter` (a typo, or an array value) is not an error: it
        defaults to `all` and is disclosed via `meta.filter_ignored` /
        `meta.filter_note`, so a client never mistakes the full list for a
        filtered one.
      parameters:
      - name: filter
        in: query
        required: false
        description: Which lane to return. Defaults to `all`.
        schema:
          type: string
          enum:
          - all
          - frontline
          - inspections
          - posts
          - tasks
          - training
          - schedule
          - approvals
          default: all
      - "$ref": "#/components/parameters/Page"
      - name: limit
        in: query
        required: false
        description: Rows per page (1–100, default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The selected lane, every lane's count, and pagination meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The lane actually rendered (the normalised `filter`).
                    example: all
                  counts:
                    type: object
                    description: Exact size of every lane over the whole union (0
                      included).
                    properties:
                      all:
                        type: integer
                        example: 12
                      frontline:
                        type: integer
                        example: 4
                      inspections:
                        type: integer
                        example: 1
                      posts:
                        type: integer
                        example: 2
                      tasks:
                        type: integer
                        example: 3
                      training:
                        type: integer
                        example: 1
                      schedule:
                        type: integer
                        example: 0
                      approvals:
                        type: integer
                        example: 1
                  degraded_sources:
                    type: array
                    description: Lanes whose owning app failed to load, in `filter`
                      vocabulary. Non-empty ⇒ the list is incomplete.
                    items:
                      type: string
                      enum:
                      - frontline
                      - inspections
                      - posts
                      - tasks
                      - training
                      - schedule
                      - approvals
                    example: []
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/UnifiedDayRow"
                  meta:
                    "$ref": "#/components/schemas/UnifiedDayListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            My Day surface is turned off (`surface_disabled`).
  "/frontline_execution/reviews":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The reviewer's queue of submitted work, with proof inline
      description: |
        Returns the submitted work waiting on this reviewer's decision, oldest
        first, span-bounded (an admin/app admin sees the whole tenant; a manager
        only their own stores). Each row carries the proof the reviewer decides
        on — photos, signature, completion note and the cached AI vision verdict.

        - **`items`** — one page of the queue (see `ReviewItem`).
        - **`campaign_id`** — the applied campaign filter, or `null`.
        - **`campaign_options`** — `{ id, name }` for every campaign with work in
          this reviewer's queue (plus the active filter), for the filter dropdown.
        - **`meta`** — the standard pagination envelope. When a `campaign_id` was
          sent that this business has no campaign for, `campaign_filter_ignored`
          is `true` and `campaign_filter_note` explains it (the queue is NOT
          silently widened).
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Page number (1-based).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (clamped 1–100).
      - name: campaign_id
        in: query
        required: false
        schema:
          type: integer
        description: Narrow the queue to one campaign. Tenant-checked; an unknown
          id is disclosed in `meta.campaign_filter_ignored` rather than widening.
      responses:
        '200':
          description: One page of the review queue for this caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign_id:
                    type: integer
                    nullable: true
                    description: The applied campaign filter, or null.
                  campaign_options:
                    type: array
                    description: Campaigns with work in this queue, for the filter
                      dropdown.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 42
                        name:
                          type: string
                          example: Endcap reset — March
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ReviewItem"
                  meta:
                    "$ref": "#/components/schemas/ReviewPaginationMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't review work (`forbidden`), or the My Day surface is turned
            off (`surface_disabled`).
  "/frontline_execution/reviews/ready_to_close":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Active campaigns whose work is fully resolved and ready to close
      description: |
        Returns the active campaigns this caller could close because every one
        of their obligations is resolved (done or missed) and none is still open,
        in progress or awaiting review. Span-bounded — an admin/app admin sees
        the whole tenant, a manager only campaigns confined to their own stores.

        - **`campaigns`** — the ready-to-close campaigns (see `ReadyToCloseCampaign`).
        - **`total_count`** — how many are returned.
        - **`capped`** — `true` when the list was cut at the 100-row cap.

        Unpaginated by design (a settled-work list, naturally small).
      responses:
        '200':
          description: The campaigns ready to close for this caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaigns:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ReadyToCloseCampaign"
                  total_count:
                    type: integer
                    example: 3
                  capped:
                    type: boolean
                    example: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't review/manage campaigns (`forbidden`), or the My Day surface
            is turned off (`surface_disabled`).
  "/frontline_execution/day_sheet":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The manager's Day Sheet — filtered, paginated to-dos with all eight
        counts
      description: |
        Returns one page of the Day Sheet for the selected `filter`, the exact
        size of ALL EIGHT filters (so the tab bar renders in one round-trip), and
        the standard pagination envelope for the selected filter.

        **Filters**

        - `everything` (default) — everything on the sheet that isn't finished
          (not done, not missed): the union of the five buckets below.
        - `escalated` — blocked work whose blocker group (campaign + reason) has
          an OPEN escalation. Overlaps `blocked`.
        - `blocked` — a worker reported a problem they can't clear.
        - `unassigned` — nobody is on the hook yet (open, in the pool).
        - `sent_back` — a reviewer sent it back to be redone (reopened).
        - `in_review` — proof is in, waiting on a reviewer (submitted).
        - `newly_assigned` — someone holds it and is working on it.
        - `completed` — done.

        Rows are ordered by triage rank (blocked → sent-back → unassigned →
        in-review → newly-assigned → completed) then soonest due then id — a
        total order, so paging is stable across requests.

        **Scoping & filters** — the sheet is span-bounded (an admin/app-admin
        sees the whole tenant, a manager only their own subtree). `location_id`
        narrows to one span-checked store (absent it, the whole span);
        `category_id` narrows to one programme. An out-of-span `location_id` is a
        `404`; an unknown `category_id` is disclosed via
        `meta.category_filter_ignored` rather than silently widening. An
        unrecognised `filter` defaults to `everything` and is disclosed via
        `meta.filter_ignored` / `meta.filter_note`.

        This is a **manager** surface — the same audience the web Day Sheet is
        gated to. A plain member is refused with `403 forbidden`.
      parameters:
      - name: filter
        in: query
        required: false
        description: Which tab to return. Defaults to `everything`.
        schema:
          type: string
          enum:
          - everything
          - escalated
          - blocked
          - unassigned
          - sent_back
          - in_review
          - newly_assigned
          - completed
          default: everything
      - name: location_id
        in: query
        required: false
        description: Narrow the sheet to one store (must be an active physical site
          inside the caller's span; `404` otherwise). Absent, the whole span.
        schema:
          type: integer
      - name: category_id
        in: query
        required: false
        description: Narrow the sheet to one campaign category (programme). An unknown
          id is ignored and disclosed in `meta.category_filter_ignored`.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: limit
        in: query
        required: false
        description: Rows per page (1–100, default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The selected filter's page, all eight counts, and pagination
            meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The filter actually rendered (the normalised `filter`).
                    enum:
                    - everything
                    - escalated
                    - blocked
                    - unassigned
                    - sent_back
                    - in_review
                    - newly_assigned
                    - completed
                    example: everything
                  location_id:
                    type: integer
                    nullable: true
                    description: The applied location filter, or null for the whole
                      span.
                    example: 3369
                  category_id:
                    type: integer
                    nullable: true
                    description: The applied category filter, or null.
                    example: 42
                  counts:
                    "$ref": "#/components/schemas/DaySheetCounts"
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DaySheetListItem"
                  meta:
                    "$ref": "#/components/schemas/DaySheetListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't manage the Day Sheet (`forbidden`), or the My Day surface
            is turned off (`surface_disabled`).
        '404':
          description: The requested `location_id` is not found or is outside the
            caller's area.
  "/frontline_execution/day_sheet/filters":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The Day Sheet's category and store filter options
      description: |
        Returns the two dropdowns the Day Sheet filters on:

        - **`categories`** — every campaign category in the business, ordered as
          the picker shows them (sort_order, then name). RETIRED (inactive)
          categories are included on purpose, flagged `active: false`, so a sheet
          narrowed to a since-retired programme still resolves. Identical for
          every caller who can reach the sheet.

        - **`locations`** — the active, physical stores this caller may open the
          sheet for, ordered by name, each as `{ id, name }`. Administrative
          rollup nodes (regions/divisions) and deactivated stores are excluded.
          This list is location-scoped: an admin / app admin sees every store in
          the tenant, a manager only the stores in their own subtree.

        - **`locations_meta`** — the store list's disclosure. `total` is the true
          store count in the caller's span; `shown` is how many are returned;
          `has_more` is `true` when the list was cut at the picker cap (2,000);
          `deactivated_only` is `true` when the span holds physical stores but
          every one is switched off (so a client shows "your stores are
          deactivated" rather than "no stores imported").

        This is a **manager** surface — the same audience the web Day Sheet is
        gated to. A plain member is refused with `403 forbidden`.
      responses:
        '200':
          description: The category and store filter options for this caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DaySheetCategory"
                  locations:
                    type: array
                    description: Active physical stores in the caller's span, ordered
                      by name.
                    items:
                      "$ref": "#/components/schemas/DaySheetStore"
                  locations_meta:
                    "$ref": "#/components/schemas/DaySheetLocationsMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't manage the Day Sheet (`forbidden`), or the My Day surface
            is turned off (`surface_disabled`).
  "/frontline_execution/day_sheet/what_changed":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: What changed at one store since a chosen point (the shift-change brief)
      description: |
        Returns the "what changed" brief for ONE store across a chosen look-back
        window — the same six buckets the web Day Sheet's "What changed" card
        renders, in the order a manager acts on them.

        **Buckets** (`changes`):

        | bucket           | what it holds                                            |
        |------------------|----------------------------------------------------------|
        | `blocked`        | work flagged "I can't do this" in the window (with reason)|
        | `sent_back`      | work reopened for rework in the window                   |
        | `escalated`      | work escalated in the window (distinct items, not events)|
        | `newly_assigned` | work that landed on somebody in the window               |
        | `new_in_pool`    | unclaimed work that appeared in the claim pool           |
        | `completed`      | work finished in the window                              |

        Each bucket is capped at `bucket_limit` (10) and SAYS SO when it hits the
        cap via `summary.<bucket>.capped` — a truncated list is never presented as
        complete. `completed` and `escalated` also carry a true `total` (the two
        counted buckets), so a client can render "showing the 10 most recent of N".

        **`since_hours`** is snapped to the offered windows (4/8/12/24/48/72),
        defaulting to 12. An unrecognised value defaults to 12 and is disclosed via
        `window.since_hours_ignored` / `window.since_hours_note`, so a client never
        renders a 12-hour brief under a label it did not ask for. `window.options`
        advertises the pickable windows so a client can render the selector.

        **`location_id` is required** — the brief is per-store. A missing param is
        `422 invalid_request`; a store that is closed, an administrative (rollup)
        node, outside the caller's span, or gone is `404 not_found` (never a silent
        widening to the whole span).

        `any_changes` is false when nothing moved in the window — distinct from a
        store with no work at all. Rows are titled by campaign name and carry the
        current holder (null for claim-pool rows).
      parameters:
      - name: location_id
        in: query
        required: true
        description: The store to brief on. Must be an active physical site inside
          the caller's span.
        schema:
          type: integer
        example: 8
      - name: since_hours
        in: query
        required: false
        description: Look-back window in hours. Snapped to one of 4/8/12/24/48/72;
          defaults to 12.
        schema:
          type: integer
          enum:
          - 4
          - 8
          - 12
          - 24
          - 48
          - 72
          default: 12
      responses:
        '200':
          description: The store's what-changed brief for the selected window.
          content:
            application/json:
              schema:
                type: object
                properties:
                  location:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 8
                      name:
                        type: string
                        example: Fourth Street
                  window:
                    "$ref": "#/components/schemas/ShiftBriefWindow"
                  any_changes:
                    type: boolean
                    description: Whether anything moved in the window (distinct from
                      "no work at all").
                    example: true
                  bucket_limit:
                    type: integer
                    description: The per-bucket render cap; a bucket at this size
                      may hold more.
                    example: 10
                  summary:
                    type: object
                    description: Count + cap state per bucket. `completed`/`escalated`
                      also carry a true `total` past the cap.
                    additionalProperties:
                      "$ref": "#/components/schemas/ShiftBriefBucketSummary"
                    example:
                      newly_assigned:
                        count: 3
                        capped: false
                      new_in_pool:
                        count: 1
                        capped: false
                      blocked:
                        count: 2
                        capped: false
                      sent_back:
                        count: 0
                        capped: false
                      completed:
                        count: 10
                        capped: true
                        total: 43
                      escalated:
                        count: 1
                        capped: false
                        total: 1
                  changes:
                    type: object
                    description: The six expandable lists, in the order the card reads
                      them.
                    properties:
                      blocked:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefBlockedRow"
                      sent_back:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      escalated:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      newly_assigned:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      new_in_pool:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      completed:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefCompletedRow"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller is not a manager (`forbidden`), the caller lacks the required
            scope (`insufficient_permissions`), or the My Day surface is turned off
            (`surface_disabled`).
        '404':
          description: The store was not found or is outside the caller's span (`not_found`)
            — closed, an administrative node, or in another tenant.
        '422':
          description: "`location_id` was not supplied (`invalid_request`)."
  "/frontline_execution/passdown":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Read the current shift pass-down for a location
      description: |
        Returns the handover the incoming shift should read, or `null` when
        nothing was handed over inside the currency window (24h).

        **`passdown.status` matters.** A `machine_closed` record means the last
        shift was ended by the system — auto clock-out, a punch-device sync, a
        CSV import — so nobody was ever prompted for a handover. Clients MUST
        render that differently from `passdown: null`: the first means "nobody
        was asked", the second means "nothing was handed over". Showing a blank
        all-clear card for a machine-closed shift is the failure this endpoint
        exists to prevent.
      parameters:
      - name: location_id
        in: query
        required: true
        schema:
          type: integer
        description: A physical location inside the caller's span.
      responses:
        '200':
          description: The current pass-down, or null.
          content:
            application/json:
              schema:
                type: object
                properties:
                  location_id:
                    type: integer
                    example: 1118
                  passdown:
                    oneOf:
                    - "$ref": "#/components/schemas/ShiftPassdown"
                    - type: 'null'
        '403':
          description: Pass-downs are turned off for this business.
        '404':
          description: Location not found or outside the caller's span.
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Record a shift pass-down
      description: |
        Files a handover against a location. The newest pass-down supersedes
        earlier ones for that site; previous records remain as history.

        A submission with neither `notes` nor any `structured` value is refused
        with `422 empty` — an empty handover tells the next shift nothing, and
        recording one would let a site look covered when it is not.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - location_id
              properties:
                location_id:
                  type: integer
                  example: 1118
                notes:
                  type: string
                  example: Bed 12 family conversation still open. Cold chain checked
                    at 6pm.
                structured:
                  type: object
                  additionalProperties:
                    type: string
                  description: Free key/value counts. The instrument differs per tenant
                    and per site.
                  example:
                    headcount: '14'
                    open_items: '2'
      responses:
        '200':
          description: Pass-down recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  passdown:
                    "$ref": "#/components/schemas/ShiftPassdown"
        '403':
          description: Pass-downs are turned off for this business.
        '404':
          description: Location not found or outside the caller's span.
        '422':
          description: location_id missing, empty submission, or the record failed
            validation.
  "/frontline_execution/passdown/{id}/acknowledge":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Acknowledge a shift pass-down
      description: |
        Records that the incoming shift has read the handover. Idempotent — a
        second call on an already-acknowledged record succeeds and leaves the
        original reader and timestamp intact.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledged (or already was).
          content:
            application/json:
              schema:
                type: object
                properties:
                  passdown:
                    "$ref": "#/components/schemas/ShiftPassdown"
        '403':
          description: Pass-downs are turned off for this business.
        '404':
          description: Handover not found or outside the caller's span.
  "/frontline_execution/items/{id}":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: One obligation in full (adapts to the caller's lens)
      description: |
        Returns everything the web detail and proof surfaces show for one
        obligation, adapted to who is asking:

        - **`item`** — the common core (identity, badges, requirements,
          instruction, documents, holder/accountable, blocker/send-back context,
          challenge), plus lens-gated sections:
          - `assignment_history` and `review_proof` — for a **reviewer** (a
            manager/admin whose span covers the location). `review_proof` is
            present only for work sitting in `submitted`.
          - `capture` and `assignment_explanation` — for the **holder** (or an
            acknowledgement roster member): what's already captured, the shared
            note trail, and why the work landed on them.
        - **`viewer`** — the caller's lens (`is_holder` / `is_reviewer`) and a
          `can` map of the six item actions they may take (mirrors the web's own
          "show this button" truth; the write endpoints remain the authority).
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) id.
        schema:
          type: integer
      responses:
        '200':
          description: The obligation, with the sections the caller is entitled to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/FrontlineItemDetail"
                  viewer:
                    "$ref": "#/components/schemas/FrontlineItemViewer"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            My Day surface is turned off (`surface_disabled`), or the caller can't
            see this row (`forbidden`).
        '404':
          description: No such obligation in this business (`not_found`).
  "/frontline_execution/requests":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The Requests tab — the caller's asks, or the approval queue, filtered
        and paged
      description: |
        Returns one page of intake requests for the selected `filter`, plus every
        visible tab's count and the caller's review capability.

        - **`filter`** — the APPLIED filter (`all` / `submitted` / `approved` /
          `declined` / `approval`). Differs from the requested one when that was
          unknown or not available to the caller — see `meta.filter_ignored`.
        - **`can_review`** — whether the caller is a campaign-author gatekeeper (so
          the client draws the "For approval" tab and reads `counts.approval`).
        - **`counts`** — every visible tab's badge in one round-trip. `all` /
          `submitted` / `approved` / `declined` count the caller's OWN asks;
          `approval` (reviewers only) counts every pending ask in the business.
        - **`requests`** — one page of rows. Each row is the full request (same
          shape as the detail endpoint) plus a `viewer` block whose flags mirror
          the web buttons (`can_withdraw` on the caller's own pending ask;
          `can_approve` / `can_decline` for a gatekeeper on a pending, non-engine
          request).
        - **`meta`** — the standard pagination envelope. When the requested filter
          was not applied, `filter_ignored` is `true` and `filter_note` says why
          (a typo, or `approval` asked for by a non-reviewer) — the list is NOT
          silently widened; it falls back to `all`.
      parameters:
      - name: filter
        in: query
        required: false
        description: 'Which lens to read: `all` (default), `submitted` ("In review"),
          `approved`, `declined` — the caller''s own asks — or `approval`, the reviewer-only
          queue of everyone''s pending asks.'
        schema:
          type: string
          enum:
          - all
          - approval
          - submitted
          - approved
          - declined
          default: all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Page number (1-based).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (clamped 1–100).
      responses:
        '200':
          description: One page of requests for the selected filter, with counts and
            the caller's capability.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The applied filter (may differ from the requested
                      one — see meta).
                    example: all
                  can_review:
                    type: boolean
                    description: The caller is a campaign-author gatekeeper (the "For
                      approval" tab is theirs).
                    example: false
                  counts:
                    "$ref": "#/components/schemas/FrontlineRequestCounts"
                  requests:
                    type: array
                    items:
                      "$ref": "#/components/schemas/FrontlineRequestListRow"
                  meta:
                    "$ref": "#/components/schemas/FrontlineRequestListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            Campaigns surface is turned off (`surface_disabled`).
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Submit an intake request (the "New request" form)
      description: |
        Submits a campaign-intake request — the API twin of the web "New request"
        form every persona reaches from the Requests tab. EVERY member may file one
        ("stores can ask too"), so this rides the own-scoped write tier with no
        author gate; only the app + Campaigns-surface toggles apply.

        The request routes through `Execution::CampaignRequestCreator`, the SAME
        writer the web form uses, so a phone submission and a browser submission are
        identical: cross-tenant FK guards on department/audience/category, store-list
        resolution (multi-select ids INTERSECTED with the tenant's physical sites,
        plus pasted/uploaded store numbers), out-of-band attachment, and decision
        routing (into the tenant's configured approval workflow when one exists, else
        a notification to the gatekeepers).

        On success returns **201** with the created request in the same
        `{request, viewer}` shape the detail endpoint returns, plus:
        - **`store_list`** — how many pasted store numbers matched and which didn't
          (present only when a store list was pasted).
        - **`warnings`** — a file the request couldn't attach (reported, not fatal;
          the ask itself is saved), present only when there is something to say.

        A validation failure is **422** whose `error.details.errors` names the
        offending fields.

        Send `application/json` for a plain request, or `multipart/form-data` when
        attaching files (`attachments[]`) or uploading a store-number CSV
        (`store_list_paste_file`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/FrontlineRequestCreateBody"
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/FrontlineRequestCreateBody"
              - type: object
                properties:
                  attachments:
                    type: array
                    description: Reference files for the ask (images/PDFs/docs; oversized
                      or unsupported files are reported in `warnings`, not fatal).
                    items:
                      type: string
                      format: binary
                  store_list_paste_file:
                    type: string
                    format: binary
                    description: A CSV/text file of store numbers, resolved the same
                      way as `store_list_paste`.
      responses:
        '201':
          description: The request was submitted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
                  store_list:
                    "$ref": "#/components/schemas/FrontlineRequestStoreListOutcome"
                  warnings:
                    type: array
                    description: Non-fatal notices (e.g. a file that couldn't be attached).
                      Present only when non-empty.
                    items:
                      type: string
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            Campaigns surface is turned off (`surface_disabled`).
        '422':
          description: The request was refused (`invalid_request`). `error.details.errors`
            maps each offending field to its messages.
  "/frontline_execution/requests/{id}":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: One intake request in full (adapts to the caller's lens)
      description: |
        Returns everything the web request detail shows for one intake request,
        adapted to who is asking:

        - **`request`** — the ask itself (title, instructions, priority, work
          type, department, audience, category, desired window, estimated
          effort, target-store count, attachments), the decision trail
          (`decided_by` / `decided_at` / `decline_reason`), whether it is routed
          through a configured approval workflow (`under_engine_review`), and the
          draft campaign it minted once approved (`campaign`).
        - **`viewer`** — the caller's lens (`is_requester` / `is_gatekeeper`) and
          the three request-action flags (`can_approve`, `can_decline`,
          `can_withdraw`). These mirror the web's own "show this button" truth;
          the write endpoints remain the authority.
      parameters:
      - name: id
        in: path
        required: true
        description: The intake request (Execution::CampaignRequest) id.
        schema:
          type: integer
      responses:
        '200':
          description: The request, with the viewer block for the caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/FrontlineRequestDetail"
                  viewer:
                    "$ref": "#/components/schemas/FrontlineRequestViewer"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            Campaigns surface is turned off (`surface_disabled`), or the caller is
            neither the submitter nor a gatekeeper (`forbidden`).
        '404':
          description: No such request in this business (`not_found`).
  "/frontline_execution/requests/{id}/approve":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Approve an intake request (mints a draft campaign)
      description: |
        Approves the request: it moves to `approved`, the caller is stamped as
        `decided_by`, and a pre-filled DRAFT `Execution::Campaign` is minted from
        the ask and linked as `request.campaign`.

        Returns the decided request in the same `{request, viewer}` shape the
        detail endpoint returns — now approved (so `viewer.can_approve` is false)
        and carrying the minted draft under `request.campaign`, so a client can
        render the new state and open the draft without a second round-trip.

        Refused (`409`) when the request was already decided or is no longer
        pending (`already_decided` — including losing a race to another gatekeeper
        or the approval engine), or when a configured approval workflow owns the
        decision (`engine_owned`). Refused (`422`, `invalid`) when the draft can't
        be built.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The intake request (Execution::CampaignRequest) to approve.
      responses:
        '200':
          description: Approved. Returns the decided request and the caller's viewer
            block.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller is not in the campaign-author
            gatekeeper tier (`forbidden`).
        '404':
          description: No such request in this business (`not_found`).
        '409':
          description: 'The request can''t be decided here: `already_decided` (already
            decided, no longer pending, or a concurrent decision won) or `engine_owned`
            (a configured approval workflow owns the decision).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
        '422':
          description: The draft campaign could not be built (`invalid`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
  "/frontline_execution/requests/{id}/decline":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Decline an intake request (reason required)
      description: |
        Declines the request: it moves to `declined`, the caller is stamped as
        `decided_by`, and the `decline_reason` is stored and sent back to the
        requester.

        The `decline_reason` is **required** — it is the only thing the person who
        asked sees, so a reason-free decline leaves them nothing to fix and
        resubmit. A blank, non-string, or shorter-than-4-character reason is
        refused with `reason_required` and the request stays pending.

        Returns the decided request in the `{request, viewer}` shape (now declined,
        carrying the `decline_reason`).

        Refused (`409`) when already decided (`already_decided`) or owned by a
        configured approval workflow (`engine_owned`).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The intake request (Execution::CampaignRequest) to decline.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - decline_reason
              properties:
                decline_reason:
                  type: string
                  minLength: 4
                  description: Why the request is declined (required, ≥ 4 chars).
                    Shown to the requester on their request and in the notification.
                  example: Already covered by a live campaign — no separate work needed.
      responses:
        '200':
          description: Declined. Returns the decided request and the caller's viewer
            block.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller is not in the campaign-author
            gatekeeper tier (`forbidden`).
        '404':
          description: No such request in this business (`not_found`).
        '409':
          description: 'The request can''t be decided here: `already_decided` or `engine_owned`
            (a configured approval workflow owns the decision).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
        '422':
          description: 'The decline was refused: `reason_required` (no/blank/too-short
            reason) or `invalid`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
  "/frontline_execution/items/{id}/claim":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Claim (take) an unclaimed pool item
      description: |
        Binds the caller to an unclaimed obligation and takes it out of the claim
        pool: the item moves to `claimed`, its `current_assignee` becomes the
        caller, `claimed_at` is stamped, a `self_claim` event is written to the
        item's history, and the fulfillment the campaign describes (a Task, or an
        Inspection for an inspection campaign) is minted into the worker's list.

        Only a caller who MAY claim the item reaches the transition. The item must
        be open and unassigned, its campaign must be active, the caller must be
        mapped to the item's location, and — when the campaign restricts the work
        to a role — the caller must hold that role. A caller who fails any of
        these is refused `403` before anything is written ("This work isn't
        available for you to claim.").

        The claim is contention-safe: if another worker claims the same item in
        the same instant, the loser is refused `422 already_assigned` ("Someone
        else just claimed this work.") rather than silently displacing the winner.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (pool obligation) to claim.
      responses:
        '200':
          description: Claimed. Returns the updated item (now `claimed`, assigned
            to the caller).
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ClaimedItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: 'App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller may not claim this
            item (`forbidden` — "This work isn''t available for you to claim.": already
            assigned, campaign not active, wrong location, or missing the required
            role).'
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: The claim was lost to a concurrent claimer (`already_assigned`),
            the work is no longer open (`not_claimable`), or the worker is at their
            critical-work cap.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClaimError"
  "/frontline_execution/items/{id}/complete":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Save and mark an obligation done (with photo / signature / note proof)
      description: |
        Marks the work done, capturing any submitted proof first.

        **Proof.** When the campaign demands a photo (`require_photo`) or a
        signature (`require_signature`), the matching proof must be present on the
        record before the work can finish — otherwise the completion is refused
        with `proof_required` and nothing changes. Submit photos as `photos[]`
        (multipart), a signature as a `signature_data` data URL, and an optional
        `completion_note`. Proof capture is **partial**: a photo the server refuses
        (not an image, over 10 MB, or past the 10-photo cap) is reported in
        `warnings` rather than discarding the rest of the submission — the valid
        photos, the signature and the note are already saved.

        **Outcome.** Work whose campaign does not require review moves straight to
        `done`. Work on a `requires_review` campaign moves to `submitted` and waits
        for a reviewer (see the accept/reject endpoints); the response `item.status`
        says which happened.

        **Refused (`422`).** `proof_required` (a required photo/signature is still
        missing), `already_done` / `missed` / `in_review` (the work is already in a
        terminal or in-review state), or a completion-policy code the shared gate
        raises (`off_shift`, `off_site`, `location_required`, `not_assigned` are
        returned as `403`; other policy codes as `422`).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (the obligation) to mark done.
      requestBody:
        required: false
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                photos:
                  type: array
                  description: Photo proof (JPG, PNG, GIF, WEBP or HEIC; up to 10
                    files, each ≤ 10 MB). Files past those limits are reported in
                    `warnings`, not fatal.
                  items:
                    type: string
                    format: binary
                signature_data:
                  type: string
                  description: A signature as a data URL (`data:image/png;base64,…`
                    or `data:image/jpeg;base64,…`), e.g. from a canvas signature pad.
                completion_note:
                  type: string
                  description: Optional note stored on the work's completion-notes
                    trail (≤ 500 chars).
                  example: Endcap reset and faced; dated the stock.
                latitude:
                  type: string
                  description: Optional GPS latitude, for a geofenced campaign's site
                    check.
                longitude:
                  type: string
                  description: Optional GPS longitude, for a geofenced campaign's
                    site check.
          application/json:
            schema:
              type: object
              properties:
                signature_data:
                  type: string
                  description: A signature data URL (when no photo upload is needed).
                completion_note:
                  type: string
                  description: Optional note stored on the work's completion-notes
                    trail (≤ 500 chars).
                  example: Endcap reset and faced; dated the stock.
                latitude:
                  type: string
                longitude:
                  type: string
      responses:
        '200':
          description: The work was marked done (or submitted for review). Returns
            the updated item, plus `warnings` when part of the submitted proof was
            refused.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/CompleteItem"
                  warnings:
                    type: array
                    description: Present only when part of the proof was refused (e.g.
                      a non-image file). Human sentences, one per refused item.
                    items:
                      type: string
                    example:
                    - notes.txt isn't an image. Use a JPG, PNG, GIF, WEBP, or HEIC.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), the caller isn't the holder/admin
            (`forbidden` — "This work isn't assigned to you."), or a completion policy
            stop (`off_shift`, `off_site`, `location_required`, `not_assigned`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompleteError"
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be completed: `proof_required` (a required
            photo/signature is missing), `already_done`, `missed`, `in_review`, or
            another completion-policy refusal.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompleteError"
  "/frontline_execution/items/{id}/checklist/{checklist_item_id}":
    patch:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Mark a checklist step done (or un-tick it)
      description: |
        Ticks one checklist step on the obligation's minted checklist Task. Send
        `completed=false` to un-tick. Returns the freshly-read checklist (the same
        shape `GET /frontline_execution/items/{id}` renders under `checklist`), the
        updated obligation, whether **every** step is now done (`all_completed` — the
        cue to offer the obligation's Complete action), and the minted Task's status.

        Steps authored inline in this app carry no evidence requirements. A campaign
        pointed at a Tasks-app template can flag a step "notes required" or "photo
        required"; supply `notes` and/or a multipart `photo` when it does. A step
        that already carries notes/photos is accepted without re-supplying them.

        Ticking an already-ticked step (or un-ticking an open one) is a **success**
        no-op — the desired state already holds. When the tick lands but the parent
        Task cannot auto-complete (a missing completion requirement), the step is
        still ticked and the reason is returned under `warnings`.

        Refused (`422`): `no_checklist` (the work is not a checklist Task with steps
        — an inspection, a simple task, or an unclaimed obligation), `task_finished`
        (the Task is already completed/cancelled — reopen it first), `requires_notes`
        / `requires_photo` (the step demands evidence none was supplied for), or
        `save_failed` (the row would not save). `404` `not_found` for an unknown step.
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) whose minted work holds the step.
        schema:
          type: integer
      - name: checklist_item_id
        in: path
        required: true
        description: The checklist step (TaskChecklistItem) to tick.
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                completed:
                  type: boolean
                  default: true
                  description: Tick the step (default) or, when false, un-tick it.
                  example: true
                notes:
                  type: string
                  nullable: true
                  description: Completion notes — required only for a step flagged
                    "notes required".
                  example: Cooler at 3°C, logged on the sheet.
          multipart/form-data:
            schema:
              type: object
              properties:
                completed:
                  type: boolean
                  default: true
                notes:
                  type: string
                  nullable: true
                photo:
                  type: string
                  format: binary
                  description: A single image (PNG/JPG/GIF/WebP/HEIC/HEIF/AVIF, ≤10MB)
                    — required only for a step flagged "photo required".
      responses:
        '200':
          description: The step was updated. Returns the refreshed checklist and obligation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineChecklistItemResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller is not the holder of
            this work and not an admin (`forbidden`).
        '404':
          description: No such obligation in this business, or no such step on its
            work (`not_found`).
        '422':
          description: 'The tick was refused: `no_checklist`, `task_finished`, `requires_notes`,
            `requires_photo`, `photo_failed` or `save_failed`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineChecklistItemError"
  "/frontline_execution/items/{id}/accept":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Accept (approve) a submitted item
      description: |
        Approves the submission: the work moves to `done`, the caller is stamped
        as the reviewer, and the optional `note` is stored as the review note.

        Refused (`422`) when the work is not awaiting review (`not_submitted` —
        including losing a race to another reviewer), or when the caller submitted
        the work themselves and separation of duties bars self-review
        (`self_review`).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (submitted work) to accept.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Optional note stored with the approval.
                  example: Approved — matches the planogram.
      responses:
        '200':
          description: Accepted. Returns the updated item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ReviewDecisionItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), or the caller can't review work
            at this location (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be accepted: `not_submitted` (not awaiting
            review, or a concurrent reviewer decided first) or `self_review` (the
            reviewer did the work).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReviewDecisionError"
  "/frontline_execution/items/{id}/reject":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Reject (send back for rework) a submitted item
      description: |
        Sends the submission back: the work moves to `reopened` and the `note` is
        stored as the review note and sent to the worker.

        The `note` (the reason) is **required** — it is the only thing the person
        who did the work sees, so a reason-free send-back leaves them nothing to
        fix. A blank or too-short reason is refused with `reason_required`, and the
        work stays in review.

        Also refused (`422`) when the work is not awaiting review (`not_submitted`)
        or the caller submitted it themselves (`self_review`).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (submitted work) to send back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note
              properties:
                note:
                  type: string
                  description: The reason for the send-back (required). It is shown
                    to the worker on their My Day card and in the notification.
                  example: Reshoot the left bay — it's out of focus.
      responses:
        '200':
          description: Sent back for rework. Returns the updated item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ReviewDecisionItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), or the caller can't review work
            at this location (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be rejected: `reason_required` (no/blank reason),
            `not_submitted` (not awaiting review, or a concurrent reviewer decided
            first) or `self_review` (the reviewer did the work).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReviewDecisionError"
  "/frontline_execution/items/{id}/block":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Flag an obligation as blocked ("I can't do this")
      description: |
        Records that the caller cannot do this work, with a structured `reason`
        and an optional free-text `note`, and notifies the location's accountable
        manager. Returns the item with its `blocked` state so a client can confirm
        the flag took.

        The `blocked_reason` is **required** and must be one of the allowed
        causes — a flag with no cause tells the manager nothing. A blank or
        unknown reason is refused with `invalid_reason`.

        A byte-identical re-flag (same reason and note on already-blocked work) is
        absorbed as a no-op success — it does not re-notify. Changing the reason
        or the note is a legitimate correction and goes through, records, and
        re-notifies.

        Refused (`422`) when the work is already finished (`already_done`) or is
        waiting on a reviewer (`in_review`).
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) to flag.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - blocked_reason
              properties:
                blocked_reason:
                  type: string
                  description: What is blocking this work (required). One of the fixed
                    set of causes, so the manager and the analytics rollup can tell
                    them apart.
                  enum:
                  - no_stock
                  - missing_fixture_or_supplies
                  - equipment_down
                  - not_enough_time
                  - unclear_instructions
                  - other
                  example: no_stock
                blocked_note:
                  type: string
                  description: Optional free-text detail for the manager (e.g. "Truck
                    is late — expected 2pm"). Capped at 500 characters (truncated,
                    not refused).
                  example: Fridge 2 alarm is sounding, panel reads ERR. Called facilities
                    at 9:20.
      responses:
        '200':
          description: Flagged (or the identical re-flag absorbed). Returns the item
            with its blocked state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/FrontlineBlockedItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or a plain member may not flag this
            work (`forbidden` — not the holder, and not on the roster for a must-read).
        '404':
          description: No such obligation in this business (`not_found`).
        '422':
          description: 'The flag was refused: `invalid_reason` (missing/blank/unknown
            reason), `not_assigned` (a non-holder — even an admin — flagging assigned
            work, or a non-roster caller flagging a must-read), `already_done` (finished
            work) or `in_review` (waiting on a reviewer).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineBlockError"
  "/frontline_execution/campaigns/{campaign_id}/questions":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Read a campaign's Q&A thread
      description: |
        Returns the campaign's questions, each with its replies, newest questions
        first (re-ordered to read top-to-bottom). The thread is the most recent
        slice; `meta.truncated` is true when there are more questions than the
        slice, and `question_count` is the true total (top-level questions) so a
        client can render the "N asked" counter.

        Readable by the author tier or anyone who holds the campaign's work,
        regardless of the campaign's status — a closed campaign's answers stay
        legible to every store that worked it.
      parameters:
      - name: campaign_id
        in: path
        required: true
        description: The campaign whose Q&A thread to read.
        schema:
          type: integer
      responses:
        '200':
          description: The Q&A thread.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineQuestionThread"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a read scope (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller may not see this campaign's
            questions (`forbidden` — not the author tier and not a holder).
        '404':
          description: No such campaign in this business (`not_found`).
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Ask a question on a campaign (or reply to one)
      description: |
        Posts a new question, or — with `parent_comment_id` — a reply aimed at an
        existing question, and notifies HQ (the campaign's author and everyone
        already in the thread, capped and deduped). Returns the created comment,
        how many were `notified`, a human `message` to flash, and the updated
        `question_count`.

        The `body` is **required** and capped at 2000 characters. A blank body is
        refused with `blank`; an over-length body with `too_long`; a
        `parent_comment_id` that does not resolve to one of this campaign's own
        top-level questions with `invalid_parent` (404). A closed campaign refuses
        a new question with `forbidden`.
      parameters:
      - name: campaign_id
        in: path
        required: true
        description: The campaign to ask on.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: The question (or reply) text. May contain @mentions.
                  example: Do the small-format stores skip the endcap, or is it required
                    everywhere?
                parent_comment_id:
                  type: integer
                  nullable: true
                  description: Omit to ask a NEW question. Set to a top-level question's
                    id on this campaign to REPLY to it (replies go one level deep).
                  example: 512
      responses:
        '200':
          description: Posted. Returns the created comment, the notified count and
            the new question total.
          content:
            application/json:
              schema:
                type: object
                properties:
                  question:
                    "$ref": "#/components/schemas/FrontlineQuestion"
                  notified:
                    type: integer
                    description: How many people were actually notified (0 when there
                      is no one but the poster).
                    example: 1
                  message:
                    type: string
                    description: A human notice describing what happened.
                    example: Question posted — HQ is notified.
                  question_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller may not ask on this
            campaign (`forbidden` — not the author tier and not a holder, or the campaign
            is closed).
        '404':
          description: No such campaign in this business (`not_found`), or a `parent_comment_id`
            that does not resolve to one of this campaign's top-level questions (`invalid_parent`).
        '422':
          description: 'The body was refused: `blank` (empty) or `too_long` (over
            2000 characters).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineQuestionError"
  "/frontline_execution/items/{id}/release":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Release (give back) an assigned item
      description: |
        Detaches the holder from the work and returns it to the pool: the item
        moves to `open`, its `current_assignee` is cleared, and a `release` event
        is written to the item's history naming the person who held it (so "who
        held it" reads the same in the history even when an admin does the
        releasing). Any open "this shouldn't be mine" challenge on the item is
        withdrawn — releasing answers it. The obligation, its due date and its
        location are unchanged.

        Only the HOLDER of the item, or an admin, may release it. A caller who is
        neither is refused `403` before anything is written.

        Refused (`422`) when the work is already finished (`already_done`), is
        waiting on a reviewer (`in_review`), or is not currently assigned to
        anyone (`not_assigned` — there is nothing to release).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (assigned work) to release.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional note stored on the release event (why the
                    work was handed back). Unlike a manager's return-to-pool, the
                    holder's own release does not require a reason.
                  example: My shift is ending — someone on the next shift can pick
                    this up.
      responses:
        '200':
          description: Released. Returns the updated item (now `open`, no assignee).
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ReleasedItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller does not hold the work
            and is not an admin (`forbidden` — "This work isn't assigned to you.").
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be released: `already_done` (settled), `in_review`
            (waiting on a reviewer) or `not_assigned` (nobody holds it).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReleaseError"
  "/frontline_execution/items/{id}/note":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Add a note to an obligation's work record
      description: |
        Appends a free-text `note` to the work's shared completion-notes trail,
        records a `note_added` activity, and notifies the location's reviewer.
        Returns the item plus the note that was recorded (author, body, timestamp)
        so a client can append it to the thread without a second round-trip.

        The `note` is **required**. A blank or non-scalar value is refused with
        `invalid_note` — nothing is written.

        Refused (`422`) when the obligation cannot take a note: `acknowledgement`
        (a must-read attestation mints no work record — confirm the read instead),
        `unsupported_work` (an inspection, whose notes live beside its answers in
        Inspections), or `no_work` (the work record could not be minted yet — a
        transient state, retrying may help).
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) to note.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note
              properties:
                note:
                  type: string
                  description: The note text (required). Capped at 500 characters
                    (truncated, not refused).
                  example: Endcap reset done, but two SKUs were out of stock — flagged
                    to the DM.
      responses:
        '200':
          description: The note was recorded. Returns the item and the recorded note.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/FrontlineNoteItem"
                  note:
                    "$ref": "#/components/schemas/FrontlineRecordedNote"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller is not the holder of
            this work and not an admin (`forbidden`).
        '404':
          description: No such obligation in this business (`not_found`).
        '422':
          description: 'The note was refused: `invalid_note` (missing / blank / non-scalar),
            `acknowledgement` (a must-read obligation with no work record), `unsupported_work`
            (an inspection) or `no_work` (work not mintable yet).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineNoteError"
  "/frontline_execution/items/{id}/nudge":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Nudge the holder of one obligation (a manager reminder)
      description: |
        Sends a reminder to whoever still owes this obligation — its current
        holder, or the location's accountable manager when it is unclaimed.

        The response always names the `recipient` and reports whether the reminder
        was actually delivered:
          * `nudged: true` — a fresh reminder was sent (in-app + push).
          * `nudged: false`, `throttled: true` — nothing new was sent: the
            recipient was already reminded within the last 24 hours, or could not
            be reached (a delivery-preference block, or the app is not accessible
            to them). The same honest hedge the web Coverage notice makes.

        `note` is optional free text that replaces the default reminder body.

        Refused (`422`, `no_recipient`) when nobody holds the work and no manager
        covers its location — there is nobody to nudge.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The obligation (Execution::CampaignItem) whose holder to nudge.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Optional message that replaces the default reminder
                    body ("You still have outstanding work at <store>.").
                  example: The truck is here — please finish the reset before close.
      responses:
        '200':
          description: The nudge was processed. `nudged`/`throttled` say whether a
            reminder was actually delivered; `recipient` names who it addressed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemNudgeResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the Coverage
            surface is off (`surface_disabled`), the caller is not in the campaign-author
            tier (`forbidden`), or the item's store is outside the caller's location
            span (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: Nobody holds the work and no manager covers its location, so
            there is nobody to nudge (`no_recipient`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemNudgeError"
  "/frontline_execution/items/{id}/reassign":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Reassign one obligation to a different person (a manager hand-off)
      description: |
        Hands this obligation to a different person. The work, its deadline and its
        coverage number stay the same — only the holder changes.

        The target (`user_id`) must be an active member of the business who is
        already assigned to the item's store. A manager override is deliberately NOT
        held to the campaign's expected-role match (covering with whoever is on shift
        is the manager's call), but the target must at least work at that location —
        the same fence the claim pool enforces.

        `reason` is REQUIRED (at least 3 characters) — a hand-off is an override of
        the resolver's pick, and the reason lands on the work's history and is the
        one thing the previous holder reads.

        An unclaimed pool item can be reassigned too (the web's "Assign someone"):
        it simply becomes claimed by the named person.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The obligation (Execution::CampaignItem) to reassign.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              - reason
              properties:
                user_id:
                  type: integer
                  description: The person to hand the work to — an active member of
                    the business assigned to the item's store.
                  example: 4821
                reason:
                  type: string
                  minLength: 3
                  description: Why the work is being handed off (required). Recorded
                    on the work's assignment history and shown to the previous holder.
                  example: Priya went home sick — please finish the reset.
      responses:
        '200':
          description: The obligation was reassigned. The body carries the reloaded
            item, now held by the new person.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemReassignResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), the caller is not in the reviewer
            tier, or the item's store is outside the caller's location span (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: No `user_id` given (`invalid_request`); the target doesn't
            work at the item's store (`not_at_location`); the reason is blank or shorter
            than 3 characters (`reason_required`); the target already holds the work
            (`already_assigned`); the work is finished (`already_done`) or waiting
            on a reviewer (`in_review`); or the target has an approved absence over
            the work's window (a leave refusal).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemReassignError"
  "/frontline_execution/items/{id}/reassign_candidates":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The store roster for reassigning one obligation (with search + pagination)
      description: |
        Returns the people the obligation can be reassigned to — the active members
        assigned to the item's store, ranked most-available first (on shift, then
        least loaded, then name). Each row carries the availability signals the web
        reassign dropdown shows, and flags the current holder.

        `q` narrows the roster by name (first name, last name, or the two joined).
        `page` and `limit` page the ranked result. The base roster is capped at 100
        people before ranking (the web renders every one as an option); when a store
        has more, `meta.roster_capped` is true and `q` is how to reach the rest.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The obligation (Execution::CampaignItem) being reassigned.
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Narrow the roster by name (case-insensitive substring).
        example: priya
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
        description: Page number (default 1).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (1–100, default 25).
      responses:
        '200':
          description: The ranked store roster for this obligation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReassignCandidatesResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `read:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), the caller is not in the reviewer
            tier, or the item's store is outside the caller's location span (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
  "/frontline_execution/coverage":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The persona-scoped Coverage rollup (nodes / campaign lens)
      description: |
        Returns the rollup for wherever the caller's span sits, in one of two
        lenses. `node` drills into a child location; `view` picks the lens;
        `status` filters the campaign lens.
      parameters:
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: The child location to drill into (a region → its districts, a
          district → its stores). Omitted, the response opens at the top of the caller's
          span. A node outside the span returns empty nodes rather than leaking.
      - name: view
        in: query
        required: false
        schema:
          type: string
          enum:
          - nodes
          - campaign
        description: The lens. Omitted, the server picks the span's natural default
          (returned in `default_view`). `nodes` is coerced to `campaign` when the
          viewer has no child locations to descend into.
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - active
          - closed
          - all
          default: active
        description: Campaign-lens status filter. `active` (default) is the live work;
          `closed` the finished programmes; `all` both. Scheduled campaigns never
          appear — they have no minted work to roll up.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Page of the campaign lens (25 per page).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Campaign-lens page size (clamped 1–100).
      responses:
        '200':
          description: The rollup for the caller's current node and lens.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageDashboard"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `read:execution` (`insufficient_permissions`), the Coverage
            surface is off (`surface_disabled`), or the caller is not in the campaign-author
            tier (`forbidden`).
  "/frontline_execution/coverage/nudge":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Nudge a district (or a campaign's lagging districts) — the rollup,
        not a task
      description: |
        Sends the rollup to the accountable manager(s) of a lagging node. Pass
        `node` for one district, or `campaign_id` for a campaign's lagging children.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                node:
                  type: integer
                  description: The location (district) to nudge — its accountable
                    manager receives the rollup.
                  example: 42
                campaign_id:
                  type: integer
                  description: Nudge the managers of the child nodes lagging on this
                    campaign (below 90%). Combine with `node` to narrow the scope.
                  example: 17
      responses:
        '200':
          description: The pass ran (including the all-nudged-today case, which reports
            nudged_count 0).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageNudgeResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `write:execution`,
            the Coverage surface is off (`surface_disabled`), or the caller is not
            in the campaign-author tier.
        '404':
          description: A `campaign_id` that isn't this tenant's (`not_found`).
        '422':
          description: Neither a node nor a campaign was given (`nothing_to_nudge`),
            or nothing is below the lag threshold (`nothing_behind`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageNudgeError"
  "/frontline_execution/campaigns/{id}/coverage/remind":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Remind everyone still outstanding on one campaign (span-bounded)
      description: |
        Sends a reminder to every person still holding outstanding work on this
        campaign inside the caller's span. Idempotent within 24h per person
        (throttled), and capped at 200 deliveries per call.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign to remind on.
      responses:
        '200':
          description: The pass ran (including the empty-roster case, which is a clean
            200 saying nobody is outstanding).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageRemindResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `write:execution`
            (`insufficient_permissions`), the Coverage surface is off (`surface_disabled`),
            or the caller is not in the campaign-author tier.
        '404':
          description: No such campaign in this business (`not_found`).
  "/frontline_execution/campaigns/held":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: List campaigns held in the publication release gate
      description: |
        Returns the scheduled campaigns awaiting publication approval, newest
        submission last (the gate is a queue). Capped and unpaginated — held work
        is rare. Each row carries who submitted it and when; the full release
        spec and whether the caller may decide it come from
        `GET /campaigns/{id}/release`.
      responses:
        '200':
          description: The held list (possibly empty).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/HeldCampaignsResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `read:execution`
            (`insufficient_permissions`), the Campaigns surface is off (`surface_disabled`),
            or the caller is not in the campaign-author tier.
  "/frontline_execution/campaigns/{id}/release":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The release-review spec for one held/scheduled campaign
      description: |
        Composes the release-review payload for a draft or scheduled campaign.
        `sites`/`obligations` are the projected target size (one obligation per
        resolved location); `same_day` lists the other campaigns landing on the
        same start date and the total obligations that day. `approval.can_decide`
        is the approval engine's own authority check for the caller (false for the
        submitter — separation of duties — and for anyone outside the approver set
        or span).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign to review.
      responses:
        '200':
          description: The release-review spec.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReleaseReview"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `read:execution`,
            the Campaigns surface is off, or the caller is not in the author tier.
        '404':
          description: No such campaign in this business (`not_found`).
        '422':
          description: The campaign has already launched — nothing to release (`not_releasable`).
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Release a held campaign (approve its publication)
      description: |
        Approves the open publication request so the release gate opens. The
        campaign keeps its scheduled status and launches on its own date — this
        never launches it directly.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The held campaign to release.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  description: Optional note recorded on the approval action.
      responses:
        '200':
          description: Released — the publication request is approved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PublicationDecisionResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `write:execution`,
            the Campaigns surface is off, the caller is not in the author tier, or
            the caller may not decide THIS request (`not_authorized_to_approve` —
            e.g. they submitted it, or it's outside their span).
        '404':
          description: No such campaign in this business (`not_found`).
        '409':
          description: The campaign isn't awaiting release approval (`not_under_review`)
            or has already launched (`already_launched`).
  "/frontline_execution/campaigns/{id}/hold":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Hold a campaign (reject its publication)
      description: |
        Rejects the open publication request. A scheduled campaign is returned to
        draft so it won't auto-launch, and its author is notified with the reason.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The held campaign to hold.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional reason surfaced to the author.
      responses:
        '200':
          description: Held — the publication request is rejected and the campaign
            returned to draft.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PublicationDecisionResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: Same fences as release, including `not_authorized_to_approve`
            when the caller may not decide this request.
        '404':
          description: No such campaign in this business (`not_found`).
        '409':
          description: Not awaiting approval (`not_under_review`) or already launched
            (`already_launched`).
  "/live_boards/boards":
    get:
      tags:
      - Live Boards
      summary: List boards
      description: |
        Boards visible to the caller.

        **Authoring tier** gets every board in the tenant. `status` selects
        `active` (default), `archived`, or `all`, and each row carries every
        active reading on the board.

        **Members** get only boards the audience gate admits them to — a board
        is included when AT LEAST ONE reading on it is readable — and each row
        carries only the readings they are admitted to. `visualizations_count`
        is that admitted count, never the board's true total, so the number a
        member is shown always matches the list beside it. `status` is ignored
        for members: an archived board renders nowhere.

        A member's list resolves its audience gate in Ruby and therefore scans
        a bounded number of boards. `meta.scan_truncated` reports whether that
        bound was reached — it is present on the member response only.

        Readings do NOT carry their data here (one source query per reading
        would make a page of boards expensive); use the detail endpoint.
      parameters:
      - name: status
        in: query
        required: false
        description: Authoring tier only. One of `active` (default), `archived`, `all`.
        schema:
          type: string
          enum:
          - active
          - archived
          - all
      - name: q
        in: query
        required: false
        description: Case-insensitive board-name search.
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: Boards the caller may see
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LiveBoard"
                  total_count:
                    type: integer
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                      scan_truncated:
                        type: boolean
                        description: Member responses only. True when the audience
                          scan hit its bound, i.e. the tenant holds more boards than
                          were examined.
        '403':
          description: Live Boards is not enabled for the business, or not published
            to users
  "/live_boards/boards/{id}":
    get:
      tags:
      - Live Boards
      summary: Get a board with its current readings
      description: |
        The board plus every reading the caller is admitted to, each with its
        current data.

        Returns **404** — not 403 — for a member admitted to no reading on the
        board. Which of "this board is empty" and "you are not admitted to it"
        applies is itself information about a board the caller may not see. The
        authoring tier is exempt and may open any board.

        Each reading's `data` object is shaped by its visualization type
        (`ranked_list`, `stat`, `line`, `table`, `chart`, `movers`, `gauge`,
        `status`, `share`, `calendar`, `streak`, `pace`, `scorecard`); the
        common members are documented on `LiveBoardReadingData`. A reading whose
        source is unavailable returns `{ "error": "..." }` in place of its data
        rather than failing the whole board.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The board and its readable readings
          content:
            application/json:
              schema:
                type: object
                properties:
                  board:
                    "$ref": "#/components/schemas/LiveBoard"
        '404':
          description: No such board in this business, or the caller is admitted to
            nothing on it
        '403':
          description: Live Boards is not enabled for the business, or not published
            to users
  "/live_boards/playlists":
    get:
      tags:
      - Live Boards
      summary: List rotations (playlists)
      description: |
        Named, ordered board rotations — what a break-room screen plays.

        **Authoring tier only** (contributor or admin); members get 403. A
        rotation is authoring material, matching the web, where the Playlists
        tab is contributor-gated.

        `rotation_url` and `embed_allowed_origins` are returned **to app admins
        only**. The rotation URL is an unauthenticated credential: anyone
        holding it reads the rotation with no sign-in, so every control over it
        is admin-tier on every surface. The raw token is never serialized on its
        own.
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - active
          - archived
          - all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: Rotations in this business
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LiveBoardPlaylist"
                  total_count:
                    type: integer
        '403':
          description: The caller is not a Live Boards contributor or admin
  "/live_boards/playlists/{id}":
    get:
      tags:
      - Live Boards
      summary: Get a rotation with its boards
      description: |
        As the list endpoint, plus `boards` — the rotation's readings resolved
        in play order and re-checked against the audience gate. Board ids are
        frozen at authoring time, so a reading that has since been archived or
        restricted is omitted rather than named.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The rotation
          content:
            application/json:
              schema:
                type: object
                properties:
                  playlist:
                    "$ref": "#/components/schemas/LiveBoardPlaylist"
        '404':
          description: No such rotation in this business
        '403':
          description: The caller is not a Live Boards contributor or admin
  "/surveys/surveys":
    get:
      tags:
      - Surveys
      summary: List surveys
      description: |
        Two lists behind one route, selected by `scope`.

        **Employee self-service (default, `scope` omitted).** Surveys the caller
        is assigned to, currently open, and has not yet completed — the set
        `Survey.available_for_user` produces minus the ones they have already
        answered. Ordered by `closes_at` ascending (nulls last), then newest
        first. Rows carry no `response_count`.

        **`scope=created`.** The authoring list: surveys the caller may manage
        (their own plus any explicitly shared with them; every survey in the
        tenant for an admin), in ALL statuses, newest first. Reachability is the
        desktop authoring gate verbatim — the tenant's "who can create and
        manage surveys" capability, OR an explicit collaborator grant. Bare
        authorship is deliberately NOT enough. Rows carry `response_count`.
        A create-capable user with no surveys yet gets an empty list, not a 403.

        Both branches accept `status`, `survey_type` and `search`.

        **Unusable parameters are REFUSED, never silently ignored** — an API
        caller has a machine-readable channel, so a filter this endpoint cannot
        honour is a `400` naming the accepted set rather than a `200` whose body
        quietly ignored it. `scope`, `status` and `survey_type` are allowlisted
        against their enums; `status`, `survey_type` and `search` must each be a
        single value (a list- or object-shaped value is a `400`, checked against
        the raw query string).

        `page` is clamped to a ceiling of 1,000,000 before it reaches the
        paginator, so an out-of-range page number cannot 500 the request.

        Use this to list my surveys, show surveys assigned to me, see open
        surveys, check pending surveys, or list the surveys I manage.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        description: Omit for the caller's own assigned-and-open surveys. `created`
          switches to the authoring list. Any other value — including a different
          case, or a list-shaped value — is a 400 `invalid_scope`.
        schema:
          type: string
          enum:
          - created
      - name: status
        in: query
        required: false
        description: Narrow to one lifecycle status. Mostly useful with `scope=created`;
          the employee list is already open-surveys-only.
        schema:
          type: string
          enum:
          - draft
          - active
          - closed
          - archived
      - name: survey_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - engagement
          - pulse
          - custom
          - manager_feedback
      - name: search
        in: query
        required: false
        description: Case-insensitive match on the survey's name and description only
          (never the creator's name — `survey_json` returns no creator, so matching
          on one would let a caller probe user names through a field the response
          does not contain). Whitespace-tokenised; tokens are ANDed; at most the first
          12 words are used, and a longer search is truncated rather than refused.
        schema:
          type: string
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The surveys the caller may see for the requested scope
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SurveySummary"
                  meta:
                    "$ref": "#/components/schemas/SurveysPaginationMeta"
                  total_count:
                    type: integer
                    description: Total rows across all pages (same value as `meta.total_count`).
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCountRef"
                  _meta:
                    "$ref": "#/components/schemas/PlatformHttpMeta"
        '304':
          description: The caller sent `If-None-Match` and it matched the ETag in
            `_meta.http.caching`. Body is empty. Only this endpoint can answer 304
            — the other reads here do not render through the piggyback path.
        '400':
          description: |
            An unusable query parameter. `error.code` is one of:

            * `invalid_scope` — `scope` is neither absent nor `created`, or is
              list-shaped. `error.details.accepted_values` names the set.
            * `invalid_status` / `invalid_survey_type` — the value is outside
              the enum. `error.details.accepted_values` names the set.
            * `invalid_status` / `invalid_survey_type` / `invalid_search` with
              `error.details.parameter` — that key arrived list- or
              object-shaped and must be a single value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to Surveys (app not
              published to users, or a visibility rule excludes them).
            * `forbidden` — `scope=created` and the caller neither holds the
              create-and-manage capability nor an explicit collaborator grant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/{id}":
    get:
      tags:
      - Surveys
      summary: Get a survey with its questions
      description: |
        The survey plus its ordered question list — the payload a native client
        renders the "take this survey" screen from.

        **Visibility.** Returned only when the caller may open the definition
        (owner, collaborator, or admin) OR the survey is currently open and the
        caller is in its audience. A hidden (link-only) survey reached directly
        by id still resolves for someone assigned to it, so onboarding- and
        PM-embedded surveys work. Anything else is `404`, never `403` — which of
        "no such survey" and "not yours" applies is itself information about a
        survey the caller may not see.

        **Questions** are the caller-fillable input fields only, in builder
        order; instruction / section / header display blocks are excluded.

        **Management numbers are tied to the caller's results TIER for THIS
        survey, not to a blanket capability:**

        * `:all` / `:aggregate` — `response_count`, `target_audience_count` and
          `completion_rate` (the whole-survey figures, matching the web).
        * `:team` — `response_count` ONLY, counting the caller's own direct
          reports, and `null` when an anonymous survey's team count sits below
          the tenant's anonymity floor. No `target_audience_count`, no
          `completion_rate` — the web renders no Target or Completion tile for
          this tier either.
        * `:none` — none of the three fields is present.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The survey and its questions
          content:
            application/json:
              schema:
                type: object
                required:
                - survey
                properties:
                  survey:
                    "$ref": "#/components/schemas/SurveyDetail"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`feature_not_enabled` (Surveys is not enabled for this business)
            or `forbidden` (the caller does not have access to the Surveys app)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business, or the caller
            may neither take nor manage it. Both cases answer the same way on purpose."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/{id}/respond":
    post:
      tags:
      - Surveys
      summary: Submit a response to a survey
      description: |
        Records the caller's answers. Re-checks the same guards the in-app
        submit path checks, then builds the submission the same way — including
        anonymity handling: `user_id` is always retained for completion
        tracking, `is_anonymous` hides identity on every display surface, and on
        an anonymous survey the stored metadata omits user agent and IP.

        Answers go in `submission_data`, keyed by each question's `field_name`
        (from the detail endpoint's `questions`). Keys the template does not
        define are dropped. A payload from which not one recognised key could be
        read is refused with `empty_submission` rather than saved as a blank
        response — an accepted blank response would permanently lock the
        respondent out and inflate everyone's completion rate. A respondent who
        legitimately leaves every OPTIONAL question blank still posts those
        keys, so `{"comments": ""}` is accepted.

        **Attachments are not supported on this endpoint and it says so** rather
        than silently discarding them: this controller runs no upload pipeline,
        so a survey with a required upload field is structurally un-completable
        here, and a multipart body carrying a file is refused. Complete those in
        the app or from the survey's own link.

        On success any in-progress draft for this template is cleared, after any
        files already attached to that draft are transferred to the new
        submission.

        On success this answers **200**, not 201 — the endpoint renders through
        `render_single`, so the body is the single `submission` key with no
        piggyback and no `Location` header.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission_data:
                  type: object
                  description: |
                    Answers keyed by `field_name`. The permitted shape depends
                    on the question's `field_type`:

                    * an ARRAY for `checkbox`, `multiselect`, `multi_choice`,
                      `multiple_choice`, `gallery`, `file`, `image`, `video`,
                      `audio`, `lookup`;
                    * an OBJECT for `matrix`, `annotation`, `range`, and for a
                      Likert-configured `scale` (one whose configuration carries
                      both `scale_options` and `statements`);
                    * a SCALAR for everything else. A container value on a
                      `rating` / `number` / `slider` / plain `scale` question is
                      dropped by the permit rather than stored.

                    An uploaded file is always dropped and then reported — see
                    `upload_not_supported` below.
                  additionalProperties: true
              required:
              - submission_data
      responses:
        '200':
          description: The response was recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - submission
                properties:
                  submission:
                    "$ref": "#/components/schemas/SurveySubmission"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `forbidden` — the caller is not in this survey's audience.
            * `insufficient_scope` — the API token declares scopes but none of
              them is a write scope, so it may not perform this action.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`already_completed` — the caller has already responded and
            the survey's own template does not allow multiple submissions."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            Either a refusal with an `error` object, or model validation
            failures with an `errors` array. The refusal codes are:

            * `survey_not_active` — the survey is not `active`, or the current
              time is outside its `opens_at`/`closes_at` window.
            * `no_questions` — the survey has no fillable questions.
            * `upload_not_supported` — the survey has a required upload field
              that this endpoint structurally cannot satisfy
              (`error.details.required_upload_field_names`), or the request
              carried an uploaded file
              (`error.details.rejected_upload_field_names`). Nothing was saved
              in either case.
            * `empty_submission` — no recognised answer key was found.
              `error.details.expected_field_names` lists the keys this template
              reads, in question order.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            description: Field name with validation error
                          message:
                            type: string
                            description: Validation error message
  "/surveys/surveys/{id}/results":
    get:
      tags:
      - Surveys
      summary: Get anonymity-safe results for a survey
      description: |
        Aggregate results only. Raw per-respondent rows and respondent identity
        are never returned by this endpoint, at any tier.

        **Who sees what** is `Surveys::AccessPolicy#results_scope` (see the file
        header). `:all` and `:aggregate` read the org-wide aggregate and report
        `scope: "full"`; `:team` reads a slice narrowed to the caller's own
        direct reports and reports `scope: "team"`; `:none` is a `403`.
        Confidential surveys and the per-survey manager opt-in are honoured by
        the policy itself.

        **The anonymity floor** is the tenant's `minimum_response_threshold`,
        resolved through the app's single resolver, which applies a platform
        floor of 5 that a tenant may raise but not lower. It suppresses in three
        places:

        1. On an ANONYMOUS survey whose response count (for this scope) is below
           the threshold, no aggregates at all are returned:
           `anonymity_protected: true`, `threshold_met: false`, and
           `question_summaries: []`.
        2. On an ANONYMOUS survey at the `:team` tier below the threshold, the
           participation count itself is masked to `null` — a manager with one
           direct report would otherwise learn whether that named person
           answered.
        3. Per question: a numeric or choice question with fewer answers than
           the threshold returns its count and `insufficient_data: true` in
           place of the distribution. Free-text questions return a count of
           non-blank answers and never the answers themselves, at any volume.

        Non-anonymous surveys are not suppressed at the survey level (1 and 2 do
        not apply), but per-question suppression (3) still does.

        This endpoint is not paginated: the whole result set for the caller's
        scope is rolled up in one pass.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The aggregate results the caller's tier permits
          content:
            application/json:
              schema:
                type: object
                required:
                - results
                properties:
                  results:
                    "$ref": "#/components/schemas/SurveyResults"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `forbidden` — the caller's results tier for this survey is
              `:none`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/{id}/remind":
    post:
      tags:
      - Surveys
      summary: Send a reminder to employees who have not responded
      description: |
        Queues `SurveyBulkDeliveryJob` in reminder mode. The job recomputes the
        non-responder set itself, so nothing is passed in and no recipient list
        is accepted.

        **Three gates run before anything is queued.** The caller must hold the
        survey's lifecycle tier (owner, co-owner, or admin). The survey must be
        active. And when the survey targets the ENTIRE company, the tenant's
        `send_to_entire_company` capability must also admit the caller — the
        same ceiling the web and the agent enforce; a department- or
        group-targeted reminder is unaffected by it.

        **Then three refusals establish there is somebody to send to**, computed
        exactly as the web twin computes them, so the two doors cannot disagree
        about who counts as a recipient: an empty target audience, an audience
        where everyone has already responded, and an audience whose
        non-responders all lack an email address (the reminder leg mails only
        addressable users and publishes no in-app item, so those people would
        receive nothing).

        `recipient_count` is what will actually be mailed — non-responders with
        an email address on file — not the raw non-responder count.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '202':
          description: Reminder delivery was queued
          content:
            application/json:
              schema:
                type: object
                required:
                - accepted
                - survey_id
                - recipient_count
                - message
                properties:
                  accepted:
                    type: boolean
                    example: true
                  survey_id:
                    type: integer
                  recipient_count:
                    type: integer
                    description: Non-responders with an email address on file — the
                      number that will actually be mailed.
                  message:
                    type: string
                    example: Reminder delivery has been queued for 12 employees who
                      haven't responded yet.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `forbidden` — the caller does not hold this survey's lifecycle
              tier (owner, co-owner, or admin).
            * `forbidden` — the survey targets the entire company and the
              tenant limits who may send company-wide.
            * `insufficient_scope` — the API token declares scopes but none of
              them is a write scope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            Nothing was queued. `error.code` is one of:

            * `survey_not_active` — reminders are only sent for an active
              survey.
            * `empty_audience` — no employees are in the target audience.
            * `all_responded` — every targeted employee has already responded.
            * `no_reachable_recipients` — none of the non-responders has an
              email address on file.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/feedback":
    post:
      tags:
      - Surveys
      summary: Submit anonymous feedback
      description: |
        The always-on anonymous feedback channel — the "speak up" box that is
        separate from any particular survey. The submitter's identity is not
        recorded on the row; what comes back is a one-way `claim_code` they keep
        in order to look up the status of their own submission later.

        Available only while the tenant has the anonymous feedback channel
        enabled (`anonymous_feedback_enabled`, on by default). Submitting also
        notifies the tenant's feedback triagers, matching the desktop path.

        Use this to submit anonymous feedback, report a concern anonymously,
        raise an issue without giving my name, or speak up.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feedback
              properties:
                feedback:
                  type: object
                  description: Must be an object. A missing `feedback` key, or one
                    whose value is a scalar or a list, is a 400 `parameter_missing`.
                    Only `topic` and `message` are read; anything else is dropped.
                  required:
                  - message
                  properties:
                    topic:
                      type: string
                      enum:
                      - workload
                      - safety
                      - leadership
                      - culture
                      - compensation
                      - communication
                      - other
                      default: other
                      description: Omitting the key defaults it to `other`. An EMPTY
                        STRING is not defaulted and fails validation — send the key
                        absent rather than blank.
                    message:
                      type: string
                      minLength: 10
                      maxLength: 12000
      responses:
        '201':
          description: The feedback was recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - feedback
                properties:
                  feedback:
                    "$ref": "#/components/schemas/SurveyAnonymousFeedback"
        '400':
          description: "`parameter_missing` — `feedback` was absent, or present but
            not an object."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `feature_not_enabled` — the anonymous feedback channel is turned
              off for this business.
            * `insufficient_scope` — the API token declares scopes but none of
              them is a write scope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/workspace/templates":
    get:
      tags:
      - Workspace
      summary: List workspace templates
      description: |
        The workspace templates available to the caller: the system-provided
        set plus any the tenant has authored. App-wide, so this is the one
        endpoint in the namespace with no workspace and no membership gate.

        Each row carries its full `structure` blueprint (the sections, task
        lists and seed content a workspace created from it receives), so keep
        `per_page` modest.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Templates available to the caller
          content:
            application/json:
              schema:
                type: object
                required:
                - templates
                - total_count
                - meta
                properties:
                  templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceTemplate"
                  total_count:
                    type: integer
                    description: Templates matching the request, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/templates/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The template id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One template
      description: One workspace template, with its full `structure` blueprint.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The template
          content:
            application/json:
              schema:
                type: object
                required:
                - template
                properties:
                  template:
                    "$ref": "#/components/schemas/WorkspaceTemplate"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No active template with that id is visible to this business
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces":
    get:
      tags:
      - Workspace
      summary: List the caller's workspaces
      description: |
        The workspaces the CALLER belongs to — an explicit membership or the
        workspace's notification recipient group.

        This is membership-scoped for every caller **including workspace app
        admins**, matching the web and mobile lists. An admin's list is the
        workspaces they are part of; any other workspace in the tenant is
        still reachable by addressing it directly at
        `GET /workspace/workspaces/{id}`.

        Ordered most-recently-updated first. Each row's `role` is resolved for
        the caller: their membership role, `member` for a rule-group member
        with no explicit row, or `null` when they have no role in it.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: Which workspaces to include. `active` (the default), `archived`,
          or `all`. An unrecognised value behaves as `active`.
        schema:
          type: string
          enum:
          - active
          - archived
          - all
          default: active
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: The caller's workspaces
          content:
            application/json:
              schema:
                type: object
                required:
                - workspaces
                - total_count
                - meta
                properties:
                  workspaces:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Workspace"
                  total_count:
                    type: integer
                    description: Workspaces matching the filter, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Create a workspace
      description: |
        Creates a workspace and makes the caller its owner.

        Who may create is a tenant capability policy (the Workspace app's
        `create_workspace` setting — manager-or-above by default, widenable to
        specific groups or everyone). A caller the policy excludes gets `422`
        naming the reason rather than a silent no-op.

        The new workspace has NO task list. `POST .../tasks` creates a default
        one on first use, so no extra call is needed.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                  description: The workspace name.
                  example: Acme Q3 Rollout
                description:
                  type: string
                  nullable: true
                icon:
                  type: string
                  nullable: true
                  description: Icon key, as shown in the web workspace form.
                color:
                  type: string
                  nullable: true
                  description: Colour key, as shown in the web workspace form.
      responses:
        '201':
          description: The created workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace
                properties:
                  workspace:
                    "$ref": "#/components/schemas/Workspace"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was created (error code `validation_failed`). Either
            the attributes are invalid (blank name, name already taken) or the tenant's
            `create_workspace` policy does not admit the caller. `error.details` carries
            the individual messages.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: One workspace
      description: |
        A single workspace, addressable by numeric id OR slug.

        Members and rule-group members see the workspaces they belong to;
        workspace app admins reach any workspace in the tenant, matching the
        web (`authorize_member!` admits app admins) and mobile surfaces.
        Anything else is `404`, never `403`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace
                properties:
                  workspace:
                    "$ref": "#/components/schemas/Workspace"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Workspace
      summary: Update a workspace
      description: |
        The correction path for create — rename, re-describe, restyle.

        OWNER-ONLY (a workspace app admin also qualifies), the same rule the
        web applies with `authorize_owner!`. The gate lives in the shared
        service, so this surface, the web and the Ask AI agent cannot drift.

        **Only the keys you send are written.** A request carrying just `name`
        leaves the description untouched — it does not blank it.

        `PUT` is routed to the same action and behaves identically.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                description:
                  type: string
                  nullable: true
                icon:
                  type: string
                  nullable: true
                color:
                  type: string
                  nullable: true
      responses:
        '200':
          description: The updated workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace
                properties:
                  workspace:
                    "$ref": "#/components/schemas/Workspace"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled or not accessible, or the
            caller is neither the workspace owner nor a workspace app admin (error
            code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was written; the attributes are invalid (error code
            `validation_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{id}/client-digest":
    parameters:
    - name: id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: Generate the client digest
      description: |
        Renders the workspace's client-facing digest as Markdown — the public
        message highlights, completed tasks and hill-chart positions a client
        may see.

        **OWNER-ONLY** (a workspace app admin also qualifies). This assembles
        exactly what leaves the company, so membership alone is not enough: a
        read-only viewer must not be able to produce it.

        Also requires the business-level `client_digest_enabled` toggle.

        Content marked internal-only anywhere in the workspace is excluded by
        construction — the same redaction the web digest and the client portal
        apply.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The generated digest
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace_id
                - format
                - digest
                properties:
                  workspace_id:
                    type: integer
                  format:
                    type: string
                    enum:
                    - markdown
                  digest:
                    type: string
                    description: The digest body, in Markdown.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is not the workspace owner (nor a workspace app
            admin), or client digests are disabled for this business (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The digest could not be generated (error code `digest_failed`).
            The message is deliberately fixed; the underlying detail is logged server-side
            rather than returned.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List message-board posts
      description: |
        The workspace's message board, most recent first. Every row carries a
        `comment_count`, so a caller can tell which threads have replies
        without fetching them.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Message-board posts
          content:
            application/json:
              schema:
                type: object
                required:
                - messages
                - total_count
                - meta
                properties:
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceMessage"
                  total_count:
                    type: integer
                    description: Posts on this board, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Post to the message board
      description: |
        Posts a new message as the caller. CONTRIBUTORS ONLY — a member whose
        role is `viewer` gets `403`.

        `@mentions` in the body are extracted and notified by the same service
        the web uses.

        Send `Idempotency-Key` to make a network retry replay the original
        response without posting again. `external_id` is a durable identity:
        sending the same payload and id later returns the existing message;
        reusing it for different content returns `409`.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - body
              properties:
                title:
                  type: string
                body:
                  type: string
                  description: Message body. `@mentions` are extracted and notified.
                internal_only:
                  type: boolean
                  default: false
                  description: Excludes the post from the client digest and the client
                    portal. Omitted means `false`.
                external_id:
                  type: string
                  maxLength: 255
                  description: Optional caller-owned durable identity, unique within
                    this workspace. Use it for resumable publishing jobs.
      responses:
        '200':
          description: Existing message replayed for the same `external_id` and identical
            content.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - replayed
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - true
        '201':
          description: The created message
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - replayed
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - false
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or the message board is disabled, the caller is not
            a member, or the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The idempotency key or durable `external_id` was reused for
            a different request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was created; the message is invalid (error code `validation_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One message with its comments
      description: |
        A single post together with a page of its comment thread.

        The thread is CAPPED (`comment_limit`, default 25, maximum 100) —
        `comment_count` on the message tells you whether there is more.

        A comment posted through the Workspace Client Portal has no platform
        account behind it: those carry `author.external: true`, the client's
        address as `author.email`, and a null `author.id`. This mirrors what
        the web shows internal readers.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: comment_limit
        in: query
        required: false
        description: Comments to return with the message. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The message and a page of its comments
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    allOf:
                    - "$ref": "#/components/schemas/WorkspaceMessage"
                    - type: object
                      properties:
                        comments:
                          type: array
                          items:
                            "$ref": "#/components/schemas/WorkspaceMessageComment"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or message (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Workspace
      summary: Edit a message
      description: |
        The correction path for posting. **Author-only** — or a workspace app
        admin. A contributor may post their own message but must not rewrite a
        colleague's, the same rule the web applies.

        **Only the keys you send are written**, so a request carrying just
        `title` will not blank the body. Send at least one of `title`, `body`,
        `internal_only`, or the request is refused with `422`.

        `@mentions` are re-extracted on edit. `PUT` behaves identically.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                body:
                  type: string
                internal_only:
                  type: boolean
      responses:
        '200':
          description: The updated message
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or the message board is disabled, the caller is not
            a member, the caller is a read-only viewer, or the caller is neither the
            message's author nor a workspace admin (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or message (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The idempotency key was reused for a different request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was written (error code `validation_failed`) — the
            body carried none of the editable keys, or the values are invalid.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{message_id}/attachments":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    - name: message_id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Attach a file to a message
      description: |
        Adds one file to a message authored by the caller. Workspace app admins
        may manage any message. The upload follows the same Drive attachment
        pipeline as the web composer.

        Send the body as `multipart/form-data`. Ordinary files may be up to
        10 MB; files whose media type is video may be up to 100 MB. Upload each
        file in a separate request so retries and failures are isolated. If the
        same file bytes are already attached, the API returns that attachment
        with `200` and `replayed: true` instead of storing a duplicate.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: Identical file bytes were already attached; the existing attachment
            was replayed.
          content:
            application/json:
              schema:
                type: object
                required:
                - attachment
                - message
                - replayed
                properties:
                  attachment:
                    "$ref": "#/components/schemas/WorkspaceMessageAttachment"
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - true
        '201':
          description: File attached
          content:
            application/json:
              schema:
                type: object
                required:
                - attachment
                - message
                - replayed
                properties:
                  attachment:
                    "$ref": "#/components/schemas/WorkspaceMessageAttachment"
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - false
        '400':
          description: No multipart file was supplied (error code `invalid_file`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller cannot contribute to or manage this message.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or message (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The file failed validation or storage (error code `attachment_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{message_id}/attachments/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      schema:
        type: string
    - name: message_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The Drive attachment id returned by the message API.
      schema:
        type: integer
    delete:
      tags:
      - Workspace
      summary: Remove a message attachment
      description: Soft-deletes one attachment. Author-only, or a Workspace app admin.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      responses:
        '200':
          description: Attachment removed
          content:
            application/json:
              schema:
                type: object
                required:
                - removed_attachment_id
                - message
                properties:
                  removed_attachment_id:
                    type: integer
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller cannot contribute to or manage this message.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, message, or active attachment.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The idempotency key was reused for a different request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The attachment could not be removed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List workspace tasks
      description: |
        The workspace's canvas tasks. Distinct from the org-wide Tasks API at
        `/api/v1/tasks`: both read the same underlying records, and these are
        the ones attached to a workspace.

        Ordered by due date, undated last, with a stable tiebreaker — so
        paging never shows the same task twice or skips one.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: "`active` (the default — not completed), `completed`, `overdue`
          (past due and not completed), or `all`. An unrecognised value behaves as
          `active`."
        schema:
          type: string
          enum:
          - active
          - completed
          - overdue
          - all
          default: active
      - name: assignee_id
        in: query
        required: false
        description: Only tasks assigned to this user.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 200.
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Tasks on this workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - tasks
                - total_count
                - meta
                properties:
                  tasks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceTask"
                  total_count:
                    type: integer
                    description: Tasks matching the filters, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Create a task
      description: |
        Adds a task to the workspace. CONTRIBUTORS ONLY.

        `task_list_id` (alias `list_id`) is OPTIONAL. A workspace created
        through this API has no task list, and this namespace exposes no
        task-list resource — so when none is given the first active list is
        reused, or a list named "Default" is created. Supplying a list id that
        is not in this workspace is `404`, which is distinct from supplying
        none.

        An `assignee_id` that resolves to nobody in the business is `404`, not
        a silent unassigned create; an unparseable `due_at` is `422`, not a
        silent "no due date". Assigning a non-member is refused by the service
        with `422` naming the reason.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                notes:
                  type: string
                  nullable: true
                assignee_id:
                  type: integer
                  nullable: true
                  description: A user in this business who is a member of the workspace.
                due_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: ISO 8601 datetime.
                task_list_id:
                  type: integer
                  nullable: true
                  description: A task list in THIS workspace. Omit to use (or create)
                    the workspace's default list. `list_id` is accepted as an alias.
                list_id:
                  type: integer
                  nullable: true
                  description: Alias for `task_list_id`.
                internal_only:
                  type: boolean
                  default: false
                  description: Excludes the task from the client digest and the client
                    portal.
      responses:
        '201':
          description: The created task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, the given `task_list_id` is not in this
            workspace, or the given `assignee_id` resolves to nobody in the business
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was created (error code `validation_failed`) — an unparseable
            `due_at`, an assignee who is not a workspace member, an invalid title,
            or no default task list could be created.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The task id.
      schema:
        type: integer
    patch:
      tags:
      - Workspace
      summary: Edit a task
      description: |
        Retitle, re-note, reassign, reschedule, change the recurrence, or move
        the task to another list in the SAME workspace. CONTRIBUTORS ONLY —
        any contributor may edit any task on the canvas (unlike messages,
        which are author-only).

        **Only the keys you send are written**, so a request carrying just
        `title` will not clear the due date or unassign the task. Send at least
        one editable key, or the request is refused with `422`. Send
        `assignee_id: null` or `due_at: null` to clear those explicitly.

        An `assignee_id` that resolves to nobody is `404`; an unparseable
        `due_at` is `422` rather than a silent clear. `PUT` behaves
        identically.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                notes:
                  type: string
                  nullable: true
                assignee_id:
                  type: integer
                  nullable: true
                  description: Null unassigns. An id that resolves to nobody is `404`.
                due_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: ISO 8601 datetime. Null clears the due date.
                recurring_cadence:
                  type: string
                  nullable: true
                internal_only:
                  type: boolean
                task_list_id:
                  type: integer
                  description: A task list in this workspace. `list_id` is accepted
                    as an alias.
                list_id:
                  type: integer
                  description: Alias for `task_list_id`.
      responses:
        '200':
          description: The updated task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or task, or the given `assignee_id` resolves
            to nobody in the business (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was written (error code `validation_failed`) — the
            body carried none of the editable keys, `due_at` is unparseable, or the
            values are invalid.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks/{id}/complete":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The task id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Complete a task
      description: |
        Marks the task complete. CONTRIBUTORS ONLY. Idempotent enough to retry:
        the response always carries the task's current state.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The completed task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or task (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The task was not completed (error code `validation_failed`).
            `error.details` carries both the `errors` list and the task's current
            `task` state, so a client can re-render without a second call.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks/{id}/reopen":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The task id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Reopen a task
      description: |
        The undo for complete — clears the completion. CONTRIBUTORS ONLY.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The reopened task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or task (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The task was not reopened (error code `validation_failed`).
            `error.details` carries both the `errors` list and the task's current
            `task` state.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/check-ins":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List recurring check-ins
      description: |
        The workspace's ACTIVE recurring check-ins — the questions the app asks
        members on a cadence — with the next scheduled run for each.

        Requires the business-level `enable_check_ins` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Active check-ins
          content:
            application/json:
              schema:
                type: object
                required:
                - check_ins
                - total_count
                - meta
                properties:
                  check_ins:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceCheckIn"
                  total_count:
                    type: integer
                    description: Active check-ins on this workspace, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/check-ins/my-drafts":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: My AI-drafted check-in responses awaiting approval
      description: |
        The CALLER's own AI-drafted check-in responses for this workspace that
        have not been committed yet — the queue behind the "review your draft"
        prompt on the web and mobile surfaces. Never another user's drafts.

        Approve one with the `.../responses/{response_id}/approve` endpoint;
        it then disappears from this list.

        Requires the business-level `enable_check_ins` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: The caller's pending drafts
          content:
            application/json:
              schema:
                type: object
                required:
                - drafts
                - total_count
                - meta
                properties:
                  drafts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceCheckInDraft"
                  total_count:
                    type: integer
                    description: The caller's pending drafts here, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/check-ins/responses/{response_id}/approve":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: response_id
      in: path
      required: true
      description: The draft check-in response id, from `my-drafts`.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Approve an AI-drafted check-in response
      description: |
        Commits one of the caller's AI drafts as their real check-in response,
        optionally replacing the text first. CONTRIBUTORS ONLY.

        **Only your own draft.** Someone else's is `403`, and a draft that
        lives in a different workspace is `404` even if you are a member of
        both.

        Requires the business-level `enable_check_ins` toggle. Check-ins can
        additionally be turned off for a SINGLE workspace by its owner; that
        refusal comes back as `422` with the reason, distinct from the
        business-level `403`.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                response_text:
                  type: string
                  description: Replaces the drafted text before committing. Omit to
                    commit the draft as written.
      responses:
        '200':
          description: The committed response
          content:
            application/json:
              schema:
                type: object
                required:
                - response
                properties:
                  response:
                    type: object
                    properties:
                      id:
                        type: integer
                      responded_at:
                        type: string
                        format: date-time
                        description: Set once the draft is committed.
                      response_text:
                        type: string
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or check-ins are disabled, the caller is not a member,
            the caller is a read-only viewer, or the draft belongs to another user
            (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or no such draft in THIS workspace (error
            code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The draft was not committed. Either check-ins are turned off
            for this specific workspace (error code `validation_failed`, with the
            reason), or approval failed internally (error code `approval_failed`,
            with a fixed client-safe message — the detail is logged server-side).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/events":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List schedule events
      description: |
        The workspace's schedule. `upcoming` (the default) returns events that
        have not started yet, soonest first; `upcoming=false` returns PAST
        events, most recent first — it is a genuine "past events" tab, not the
        same list reversed, and `total_count` counts only the half you asked
        for.

        `internal_only` is carried on every row: `location` is where meeting
        links live, so an integration building a client-facing calendar needs
        to know which entries must not leave the company.

        Requires the business-level `enable_schedule` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: upcoming
        in: query
        required: false
        description: "`true` (the default) for events yet to start; `false` for past
          events. Accepts JSON booleans and the usual string spellings."
        schema:
          type: boolean
          default: true
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Schedule events
          content:
            application/json:
              schema:
                type: object
                required:
                - events
                - total_count
                - meta
                properties:
                  events:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceEvent"
                  total_count:
                    type: integer
                    description: Events in the requested half (upcoming OR past),
                      across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/events/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The event id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One schedule event
      description: |
        A single event on this workspace's schedule.

        Requires the business-level `enable_schedule` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The event
          content:
            application/json:
              schema:
                type: object
                required:
                - event
                properties:
                  event:
                    "$ref": "#/components/schemas/WorkspaceEvent"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or event (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/hill-charts":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List hill charts
      description: |
        The workspace's ACTIVE hill charts — each a named scope and its
        position on the "figuring it out / making it happen" curve.

        When the Workspace agent has proposed a new position but nobody has
        accepted it yet, `ai_proposal_pending` is true and
        `ai_proposed_position` carries the suggestion; `position` still holds
        the human-set value.

        Requires the business-level `enable_hill_charts` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Active hill charts
          content:
            application/json:
              schema:
                type: object
                required:
                - hill_charts
                - total_count
                - meta
                properties:
                  hill_charts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceHillChart"
                  total_count:
                    type: integer
                    description: Active hill charts on this workspace, across all
                      pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/hill-charts/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The hill chart id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One hill chart
      description: |
        A single hill chart. Unlike the list, this reaches ARCHIVED charts too
        — `status` says which.

        Requires the business-level `enable_hill_charts` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The hill chart
          content:
            application/json:
              schema:
                type: object
                required:
                - hill_chart
                properties:
                  hill_chart:
                    "$ref": "#/components/schemas/WorkspaceHillChart"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or hill chart (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/reactions":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List reactions on one item
      description: |
        Every reaction on ONE reactable item, oldest first, with the reacting
        user on each row. Both `reactable_type` and `reactable_id` are
        required — this lists the reactions on a specific message, comment or
        task, not the workspace's reactions in bulk.

        The item must live in THIS workspace; one from another workspace is
        `404` even if the caller can see it elsewhere.

        Requires the business-level `enable_reactions` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: reactable_type
        in: query
        required: true
        description: The kind of item to read reactions for.
        schema:
          type: string
          enum:
          - WorkspaceMessage
          - WorkspaceMessageComment
          - Task
      - name: reactable_id
        in: query
        required: true
        description: The id of the item, which must belong to this workspace.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 200.
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Reactions on the item
          content:
            application/json:
              schema:
                type: object
                required:
                - reactions
                - total_count
                - meta
                properties:
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceReaction"
                  total_count:
                    type: integer
                    description: Reactions on this item, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or `reactable_type` is not one of the allowed
            types, or no item with that id in this workspace (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/reactions/toggle":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    post:
      tags:
      - Workspace
      summary: Add or remove a reaction
      description: |
        Toggles ONE emoji by the calling user on one item: present becomes
        absent, absent becomes present. `action` says which happened, and
        `counts` returns the item's full per-emoji tally so a client can
        re-render without a second call. CONTRIBUTORS ONLY.

        The emoji must be in the item's allowed set; anything else is `422`.
        Concurrent toggles are safe — a duplicate insert is treated as an add.

        Requires the business-level `enable_reactions` toggle.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reactable_type
              - reactable_id
              - emoji
              properties:
                reactable_type:
                  type: string
                  enum:
                  - WorkspaceMessage
                  - WorkspaceMessageComment
                  - Task
                reactable_id:
                  type: integer
                  description: The id of the item, which must belong to this workspace.
                emoji:
                  type: string
                  description: One emoji from the item's allowed set.
                  example: "\U0001F44D"
      responses:
        '200':
          description: The toggle result and the item's new tally
          content:
            application/json:
              schema:
                type: object
                required:
                - action
                - counts
                properties:
                  action:
                    type: string
                    enum:
                    - added
                    - removed
                  counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: Reaction count per emoji on this item, after the
                      toggle.
                    example:
                      "\U0001F44D": 3
                      "\U0001F389": 1
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or reactions are disabled, the caller is not a member,
            or the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or `reactable_type` is not one of the allowed
            types, or no item with that id in this workspace (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The emoji is not in the item's allowed set (error code `validation_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/ideas":
    post:
      tags:
      - Ideas
      summary: Post a new idea
      description: |
        Creates an idea — the native mirror of the web composer
        (`Apps::Ideas::IdeasController#create`), sharing
        `Ideas::IdeaCreationService` and `Ideas::AttachmentScreener` so the two
        surfaces produce identical rows.

        **Side effects, all matching the web:**
        * the idea lands in the workspace's **entry** lifecycle stage (a new idea is
          never dropped straight into Reviewing/Planned);
        * the **author auto-votes**, so a brand-new idea comes back with
          `vote_count: 1` and `has_voted: true` (the web's *"Idea posted — you're the
          first vote!"*);
        * `description_html` is derived from the plain text (escaped first, then
          formatted, so line breaks survive and any markup the author typed stays
          inert text);
        * an audit entry is written and the review panel is notified in the
          background.

        **Attachments are gated.** `files[]` / `file_signed_ids[]` are accepted only
        while **"Allow file attachments"** is ON for the workspace. When it is OFF the
        files are **dropped and the idea is still created** — the web behaves the same
        way (its composer simply renders no file field) — and the drop is **named
        per file in `attachment_errors` and repeated in `warnings`**. Accepted files
        are screened against a size cap and a sniffed-content-type allowlist; anything
        dropped is reported the same way. `attachment_errors` is always present (empty
        when everything attached) because a silently missing attachment is the worst
        outcome.

        **`warnings` names every field this workspace's settings discarded.** A 2xx
        that dropped part of the write says so: a file the attachments toggle refused,
        a `voting_closes_on` the close-date toggle refused, a `campaign_id` the
        campaigns toggle refused. The key is omitted entirely when nothing was
        dropped, so its presence is the signal. `GET /api/v1/ideas/config` advertises
        the same toggles if a client would rather not send the field at all.

        **Authorization** is the workspace's *"Who can submit ideas"* audience
        (`submit_audience`) — everyone, or only one configured group. Reading the
        Ideas app is not enough: a user can browse ideas and still be outside the
        posting audience (`403 forbidden`).

        **Duplicate detection** mirrors the web composer. Before creating, the title
        is matched against existing ideas (the SAME matcher — `Ideas::DuplicateFinder`
        — behind the web's nudge and its typeahead). If it looks like one or more
        existing ideas and the caller hasn't confirmed, the endpoint answers
        **`409`** with the matches — `{ duplicates_found: true, message,
        duplicates: [{ id, title, vote_count, has_voted }] }` — and **creates
        nothing**. Show them ("add your vote, or post anyway"), then re-POST with
        **`confirm_duplicates: true`** to post anyway. Detection runs only for an
        otherwise-valid idea, so a missing field still answers `422`, not `409`.

        Responds **201** with the SAME canonical idea object
        `GET /api/v1/ideas/{id}` returns.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        description: "`application/json` for a text-only idea; `multipart/form-data`
          when posting `files[]`."
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - description
              properties:
                title:
                  type: string
                  maxLength: 200
                  description: Required. Surrounding whitespace is stripped.
                  example: Dark mode for the mobile app
                description:
                  type: string
                  description: Required — the model validates its presence on create.
                  example: Please add a dark theme; the white background is rough
                    on night shift.
                campaign_id:
                  type: integer
                  nullable: true
                  description: Optional. Honoured only while the Campaigns feature
                    is on — silently ignored when it is off, so a client keeps working
                    if the workspace later disables campaigns. Must belong to this
                    business and still be open, else `422`.
                  example: 12
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                  description: Optional. Honoured only while the voting-close-date
                    feature is on; ignored otherwise. Must be on or before the campaign's
                    close date.
                  example: '2026-09-30'
                confirm_duplicates:
                  type: boolean
                  default: false
                  description: Set `true` to skip duplicate detection and post even
                    when a similar idea already exists (the web's "post anyway").
                    When omitted/false, a title that matches existing ideas answers
                    `409` with the matches instead of creating.
                  example: false
          multipart/form-data:
            schema:
              type: object
              required:
              - title
              - description
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: string
                campaign_id:
                  type: integer
                  nullable: true
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                confirm_duplicates:
                  type: boolean
                  default: false
                  description: Set `true` to skip duplicate detection and post anyway
                    (the web's "post anyway"). Otherwise a matching title answers
                    `409`.
                files[]:
                  type: array
                  description: Files to attach. Accepted ONLY while "Allow file attachments"
                    is ON; otherwise dropped (the idea is still created). Screened
                    against a size cap and a sniffed content-type allowlist (images,
                    PDF, Word/Excel/PowerPoint, plain text).
                  items:
                    type: string
                    format: binary
                file_signed_ids[]:
                  type: array
                  description: 'Alternative to `files[]` for clients that direct-upload
                    first: ActiveStorage signed ids. Re-screened server-side, and
                    an already-attached blob is rejected, so a signed id cannot be
                    replayed to steal another record''s file.'
                  items:
                    type: string
      responses:
        '201':
          description: Idea created
          content:
            application/json:
              schema:
                type: object
                required:
                - idea
                - attachment_errors
                properties:
                  idea:
                    type: object
                    description: The created idea, in the SAME shape `GET /api/v1/ideas/{id}`
                      returns — one query object and one serializer back both, so
                      the two cannot drift. See that endpoint for the full field list.
                      Note `vote_count` is 1 and `has_voted` is true on a fresh create,
                      because the author auto-votes.
                    properties:
                      id:
                        type: integer
                        example: 91
                      title:
                        type: string
                        example: Dark mode for the mobile app
                      description:
                        type: string
                        nullable: true
                      description_html:
                        type: string
                        nullable: true
                      vote_count:
                        type: integer
                        example: 1
                      has_voted:
                        type: boolean
                        example: true
                      stage:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 1
                          label:
                            type: string
                            example: New
                  attachment_errors:
                    type: array
                    description: One human-readable reason per file that was NOT attached
                      (wrong type, over the size cap, or the workspace's "Allow file
                      attachments" setting being off). Always present; empty when
                      everything attached. Never fatal — the idea is created either
                      way.
                    items:
                      type: string
                    example:
                    - payload.zip is not a supported file type and wasn't attached.
                  warnings:
                    type: array
                    description: One sentence per field this workspace's settings
                      discarded — dropped files, a refused `voting_closes_on`, a refused
                      `campaign_id`. OMITTED when nothing was dropped, so the key's
                      presence is the signal that the 2xx is a partial write.
                    items:
                      type: string
                    example:
                    - '"Allow ideas to have a voting close date" is off for this workspace
                      — voting_closes_on was ignored and no close date was set.'
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is outside the workspace's submit audience (error
            code `forbidden`), or the Ideas app is not accessible to them (`access_denied`).
        '409':
          description: 'The title matches one or more existing ideas and `confirm_duplicates`
            was not set — the matches are returned and **nothing was created**. Re-POST
            with `confirm_duplicates: true` to post anyway.'
          content:
            application/json:
              schema:
                type: object
                required:
                - duplicates_found
                - message
                - duplicates
                properties:
                  duplicates_found:
                    type: boolean
                    example: true
                  message:
                    type: string
                    description: Human-readable summary, e.g. the web's nudge headline.
                    example: 2 similar ideas already exist
                  duplicates:
                    type: array
                    description: The matching ideas, most-likely first (max 3). Just
                      enough to render the "add your vote, or post anyway" panel.
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - vote_count
                      - has_voted
                      properties:
                        id:
                          type: integer
                          example: 74
                        title:
                          type: string
                          example: Dark mode for the mobile app
                        vote_count:
                          type: integer
                          description: The idea's current upvote count.
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the CALLING user has already upvoted
                            this idea.
                          example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '422':
          description: Validation failed (error code `invalid`) — a missing/blank
            title, a title over 200 characters, a missing description, or a campaign
            that is closed or belongs to another business. Also `no_entry_stage` when
            the workspace has no entry lifecycle stage configured. Also `content_blocked`
            when the workspace's content-moderation policy refuses the submitted text;
            the message is the policy's own and is safe to show the author verbatim.
  "/ideas/dashboard":
    get:
      tags:
      - Ideas
      summary: Ideas dashboard
      description: |
        The native-client mirror of the web Ideas dashboard
        (`Apps::IdeasController#show`). Every number and list is produced by the
        SAME query object the web view uses (`Ideas::DashboardStats`), so the two
        surfaces cannot drift.

        **Persona-independent.** Unlike some dashboards, the web Ideas dashboard
        applies NO persona / visibility / status filter — an idea has no
        draft/published state and is live the moment it is created. Admins,
        reviewers and regular members therefore receive the **identical**
        payload. There is no `is_admin` branch.

        **Sections** (each list is capped at 5):
        * `total_ideas` — business-scoped count of all ideas.
        * `count_by_stage` — one row per lifecycle stage in pipeline order
          (`position` asc), with `count` defaulting to 0 — exactly the per-stage
          tiles the web renders. The counts sum to `total_ideas` (every idea is
          in exactly one stage).
        * `top_voted` — highest-voted first (the `up_votes_count` counter cache;
          positive votes only). Fields: id, title, votes, stage.
        * `recently_added` — newest first by **`created_at`** (the date the web
          renders as "N ago"; there is no separate published/submitted date).
          Fields: id, title, creator_name (the author's full name), created_at.
        * `most_discussed` — most-commented first (all non-deleted
          `Platform::Comment` on the idea, threaded replies included).
          Zero-comment ideas are **dropped** from this section, matching the web,
          so it can contain fewer than 5 (or be empty) in a quiet tenant.
          Fields: id, title, comments_count.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - dashboard
                properties:
                  dashboard:
                    type: object
                    required:
                    - total_ideas
                    - count_by_stage
                    - top_voted
                    - recently_added
                    - most_discussed
                    properties:
                      total_ideas:
                        type: integer
                        description: Count of all ideas in the business.
                        example: 42
                      count_by_stage:
                        type: array
                        description: One row per lifecycle stage, in pipeline (position)
                          order. Counts sum to total_ideas.
                        items:
                          type: object
                          required:
                          - stage_id
                          - label
                          - count
                          properties:
                            stage_id:
                              type: integer
                              example: 1
                            label:
                              type: string
                              example: New
                            color:
                              type: string
                              description: Stage colour (hex).
                              example: "#6c757d"
                            count:
                              type: integer
                              description: Ideas currently in this stage (0 when empty).
                              example: 12
                      top_voted:
                        type: array
                        description: Up to 5 ideas, highest up-votes first.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - votes
                          - stage
                          properties:
                            id:
                              type: integer
                              example: 87
                            title:
                              type: string
                              example: Dark mode for the mobile app
                            votes:
                              type: integer
                              description: Positive-vote total (up_votes_count).
                              example: 34
                            stage:
                              type: object
                              required:
                              - id
                              - label
                              properties:
                                id:
                                  type: integer
                                  example: 2
                                label:
                                  type: string
                                  example: Reviewing
                      recently_added:
                        type: array
                        description: Up to 5 ideas, newest created_at first.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - creator_name
                          - created_at
                          properties:
                            id:
                              type: integer
                              example: 91
                            title:
                              type: string
                              example: Add SSO for contractors
                            creator_name:
                              type: string
                              description: The author's full name.
                              example: Dana Lee
                            created_at:
                              type: string
                              format: date-time
                              description: When the idea was created (rendered as
                                "N ago" on web).
                              example: '2026-07-31T06:01:58Z'
                      most_discussed:
                        type: array
                        description: Up to 5 ideas with the most comments (replies
                          included), highest first. Zero-comment ideas are excluded,
                          so this may be shorter than 5 or empty.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - comments_count
                          properties:
                            id:
                              type: integer
                              example: 87
                            title:
                              type: string
                              example: Dark mode for the mobile app
                            comments_count:
                              type: integer
                              description: Non-deleted comments + replies.
                              example: 9
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/config":
    get:
      tags:
      - Ideas
      summary: Ideas workspace configuration
      description: |
        The tenant's Ideas configuration **as this caller experiences it** — the
        native mirror of what every web Ideas screen reads from
        `IdeasAppConfigurable`, and of what an admin edits at **Apps ▸ Ideas ▸
        Settings**. One call, so a client can render the app the way this workspace
        is configured without probing endpoint by endpoint.

        **Persona-aware, not persona-branched.** Every caller receives the same
        keys. The two `can_*` booleans are resolved **for the caller** under the
        exact rule the write sites enforce, so a client can show the *Submit Idea*
        and *New Campaign* affordances precisely when the server would accept
        them. There is **no admin bypass** on either: an admin configures the
        audience, they don't override it.

        **Audiences** (`submit_audience`, `campaign_creators`) are each reported as
        `{ value, type, group }`:
        * `type: "all"` — everyone in the business may do it (the default, and what
          a blank setting means).
        * `type: "group"` — only members of `group` may. `group` is `null` when the
          saved group has since been deleted or belongs to another business; the
          runtime gate denies in that case, so `can_submit_idea` /
          `can_create_campaign` is `false` — **trust the `can_*` boolean**, never
          infer permission from `type`.

        **Review panels** (`idea_reviewers`, `campaign_reviewers`) are each
        `{ group, count, source }`:
        * `count` is the panel's FULL membership size. Neither panel **names anyone**
          — there is no `members` key and the shape does not change with
          `reviewer_names_visible`. Render "Reviewed by <group.name> (<count>)" from
          this, and call a roster endpoint for the actual people — both paginated,
          name-searchable, and gated on `reviewer_names_visible`:
          `GET /ideas/{idea_id}/reviewers` for a given idea's panel, and
          `GET /ideas/campaigns/{id}/reviewers` for a given campaign's.
        * `idea_reviewers.source` is `configured` when Settings picked the group, or
          `fallback` when no group is saved and ideas therefore route to the
          built-in **All Admins** group.
        * `campaign_reviewers.source` is `configured` when Settings picked a
          campaign panel, or `inherited` when it is blank — the Settings option
          "— Same as the Idea Reviewers group —", so the group echoes
          `idea_reviewers.group`. This is the panel a NEW campaign pre-fills with;
          a creator may override it per campaign.
        * `group` is `null` (with `count: 0`) only when nothing resolves at all —
          no saved group and no All Admins group to fall back to.

        **`campaign_options`** is the option data a "New Campaign" composer needs so
        it can only offer values `POST /ideas/campaigns` accepts: the authored icon
        set, the accent palette with its hex values, this workspace's selectable
        reviewer groups, and the defaults each field falls back to when omitted. The
        reviewer-group list is empty while `campaigns_enabled` is `false`.

        **Stages** is the complete lifecycle pipeline in order (`position`
        ascending). `id` is stable across a rename, so a client may cache stage ids;
        `name` is the admin-editable label; `category`
        (`entry`/`active`/`implemented`/`declined`) is what outcome metrics key off
        — never the id. `icon` is the stage's Font Awesome glyph name with **no `fa-`
        prefix**, DERIVED from the label and category (there is no icon column and
        admins never pick one) — the same glyph the web renders for that stage, so a
        native pipeline looks like the web one. See `IdeaLifecycleStage` for the exact
        derivation. A workspace that has never opened Ideas gets the shipped default
        pipeline seeded on first read, so this array is never empty.

        **`accent`** is the workspace's accent colour, shipped as its key **and** the
        five colour tokens every web Ideas screen renders from — tint your own chrome
        from those tokens rather than a client-side palette, which would drift from the
        web the moment a token is re-tuned. See the field below for the coercion rule.

        **Deliberately not included:** the web-chrome LAYOUT settings (feed density,
        feed layout — a native client lays out its own way), the per-event `notify_*`
        toggles (delivery-side, inert for a client), and `agent_enabled` (surfaced
        through the Ask AI plumbing).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Configuration retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - config
                properties:
                  config:
                    type: object
                    required:
                    - submit_audience
                    - can_submit_idea
                    - idea_reviewers
                    - scoring_enabled
                    - reviewer_names_visible
                    - vote_icon
                    - accent
                    - vote_change_allowed
                    - close_date_enabled
                    - attachments_enabled
                    - ai_assist_enabled
                    - campaigns_enabled
                    - campaign_creators
                    - can_create_campaign
                    - can_assign_review_panel
                    - campaign_reviewers
                    - campaign_options
                    - stages
                    properties:
                      submit_audience:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasAudience"
                        description: Who can submit ideas — everyone, or one group.
                      can_submit_idea:
                        type: boolean
                        description: Whether THIS caller may submit an idea right
                          now, under the audience above. Authoritative — drive the
                          Submit affordance off this, not off `submit_audience.type`.
                        example: true
                      idea_reviewers:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasReviewPanel"
                        description: The workspace review panel every idea routes
                          to unless its campaign overrides it. `source` is `configured`
                          or `fallback`.
                      scoring_enabled:
                        type: boolean
                        description: RICE scoring (score pills, the impact/effort
                          matrix, the "Highest Score on Top" sort). Default true.
                        example: true
                      reviewer_names_visible:
                        type: boolean
                        description: Whether reviewer identities may be shown. When
                          false, BOTH roster endpoints — `GET /ideas/{idea_id}/reviewers`
                          and `GET /ideas/campaigns/{id}/reviewers` — return 403 `reviewer_names_hidden`
                          — show only the panel's group name and count. This flag
                          does NOT change any shape in this payload (no panel here
                          names anyone either way); it exists so a client hides the
                          roster affordance instead of tapping into the 403.
                        example: true
                      vote_icon:
                        type: string
                        description: |
                          The configured upvote glyph. Always a value — an unset
                          setting falls back to `caret-up`.

                          The Settings picker offers `caret-up`, `chevron-up`,
                          `arrow-up`, `circle-up` and `thumbs-up`. This field echoes
                          what is STORED rather than re-validating it (the same rule
                          the web's `vote_icon` helper applies), so treat it as an
                          open string and fall back to `caret-up` for any name you
                          don't recognise.
                        example: caret-up
                      accent:
                        type: object
                        description: |
                          The workspace accent an admin picked in Settings — the colour
                          that tints buttons, active states and subtle backgrounds on
                          every Ideas screen. Tint your own chrome from these tokens so
                          the native app matches the web.

                          Shipped as the KEY **and** its five colour tokens, from the
                          one palette the web views render from. Do not hardcode a
                          palette keyed off `key` alone: the tokens can be re-tuned
                          server-side, and a client copy would silently drift to a
                          different shade than the web.

                          `key` is whitelist-coerced to the four authored accents, so a
                          workspace whose stored value is blank, retired or hand-edited
                          reports `blue` — key and tokens together, never a key you
                          cannot resolve to a colour.
                        required:
                        - key
                        - primary
                        - bright
                        - hover
                        - subtle
                        - subtle_text
                        properties:
                          key:
                            type: string
                            enum:
                            - blue
                            - forest
                            - purple
                            - sunset
                            example: forest
                          primary:
                            type: string
                            description: Buttons
                            links:
                            the main accented fill.:
                            example: "#2d6a4f"
                          bright:
                            type: string
                            description: A lighter variant for emphasis / highlights.
                            example: "#1B8751"
                          hover:
                            type: string
                            description: The pressed / hovered state of `primary`.
                            example: "#235640"
                          subtle:
                            type: string
                            description: Tinted background for chips
                            pills and active rows.:
                            example: "#d8efe3"
                          subtle_text:
                            type: string
                            description: Readable text colour ON a `subtle` background.
                            example: "#1f5e44"
                      vote_change_allowed:
                        type: boolean
                        description: Whether a member may remove their own upvote.
                          When false, `DELETE /ideas/{idea_id}/vote` is refused —
                          hide the un-vote affordance.
                        example: true
                      close_date_enabled:
                        type: boolean
                        description: Whether ideas may carry a voting close date.
                          The one opt-in setting — default **false**. While false,
                          `voting_closes_on` is never surfaced on an idea.
                        example: false
                      attachments_enabled:
                        type: boolean
                        description: Whether ideas and comments may carry file attachments.
                          Default true.
                        example: true
                      ai_assist_enabled:
                        type: boolean
                        description: Whether AI writing assistance (expand rough notes
                          into a draft) is offered on the compose form. Default true.
                        example: true
                      campaigns_enabled:
                        type: boolean
                        description: Whether idea campaigns exist for this workspace.
                          When false, hide the Campaigns section — `GET /ideas/campaigns`
                          returns 403 `campaigns_disabled` — and `can_create_campaign`
                          is false regardless of the audience.
                        example: true
                      campaign_creators:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasAudience"
                        description: Who can launch new campaigns — everyone, or one
                          group.
                      can_create_campaign:
                        type: boolean
                        description: Whether THIS caller may create a campaign right
                          now — the audience above AND `campaigns_enabled`.
                        example: true
                      can_assign_review_panel:
                        type: boolean
                        description: 'Whether THIS caller may choose a campaign''s
                          review panel, i.e. whether the panel select in a composer
                          is live for them. The workspace capability (default: Ideas
                          admins only), so a member who may CREATE a campaign commonly
                          may not APPOINT its panel — their campaign inherits `campaign_reviewers`.
                          When this is false, `campaign_options.reviewer_groups` is
                          empty for that reason rather than because there are no groups.'
                        example: true
                      campaign_reviewers:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasReviewPanel"
                        description: The panel a NEW campaign pre-fills with. `source`
                          is `configured`, or `inherited` when it mirrors `idea_reviewers`.
                      campaign_options:
                        type: object
                        description: The option data a "New Campaign" composer needs,
                          so it can only offer values `POST /ideas/campaigns` accepts
                          — the same sets the web form draws its radio buttons and
                          select from.
                        properties:
                          icons:
                            type: array
                            description: The authored Font Awesome glyph names (no
                              `fa-` prefix) accepted as `icon`. Anything else is rejected
                              with `422 invalid_icon`.
                            items:
                              type: string
                            example:
                            - bullhorn
                            - rocket
                            - lightbulb
                          colors:
                            type: array
                            description: The accent palette. `key` is what you send
                              as `color`; the three hex values let a client render
                              the card and its chips without shipping the palette.
                            items:
                              type: object
                              properties:
                                key:
                                  type: string
                                  example: forest
                                hex:
                                  type: string
                                  example: "#2d6a4f"
                                subtle:
                                  type: string
                                  example: "#d8efe3"
                                subtle_text:
                                  type: string
                                  example: "#1f5e44"
                          reviewer_groups:
                            type: array
                            description: The groups this workspace can route a campaign
                              to, name ascending — the same list the web form's "Campaign
                              reviewers" select offers. Empty while `campaigns_enabled`
                              is false (there is no composer to render then).
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 7
                                name:
                                  type: string
                                  example: Product Council
                          default_icon:
                            type: string
                            description: Applied when `icon` is omitted.
                            example: bullhorn
                          default_color:
                            type: string
                            description: Applied when `color` is omitted.
                            example: blue
                          default_reviewer_group_id:
                            type: integer
                            nullable: true
                            description: The panel applied when `reviewer_group_id`
                              is omitted entirely (the workspace's configured campaign
                              panel). `null` means a campaign created without one
                              inherits the workspace default panel at read time.
                            example: 7
                      stages:
                        type: array
                        description: The complete lifecycle pipeline, in order (`position`
                          ascending). Never empty. Same stage shape `PATCH /ideas/{idea_id}/stage`
                          reports, so this list can populate a stage picker and its
                          result parsed identically.
                        items:
                          "$ref": "#/components/schemas/IdeaLifecycleStage"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/ai_draft":
    post:
      tags:
      - Ideas
      summary: Draft an idea description with AI
      description: |
        The native-client mirror of the web "Draft with AI" button on the idea
        compose form (`POST /apps/ideas/list/ai_draft`). Both call the SAME
        `Ideas::AiDraftService#draft_description`, so the two surfaces cannot drift.
        The campaign-brief equivalent is `POST /api/v1/ideas/campaigns/ai_brief`.

        **WRITES NOTHING.** It returns text for the caller to place in the
        description field; posting the idea is still `POST /api/v1/ideas`. Safe to
        call repeatedly, and each call re-drafts.

        **Both inputs are optional.** `title` is what the author has typed so far.
        The notes the author already wrote are read from `description`, falling back
        to `notes` — send either. Whatever was sent is echoed back as `prior_text`,
        so a client can offer "restore what I had" after replacing the field with a
        draft rather than losing the author's own words.

        **`fallback` is part of the contract, not decoration.** The drafting service
        never raises: when the LLM is unavailable it returns a locally-composed
        outline and sets `fallback: true`, with `notice` carrying the "AI is
        unavailable right now" wording. Surface `notice` as-is rather than presenting
        a fallback outline as a finished AI draft.

        **Gating** — two 403s a client should treat differently:
        * `ai_assist_disabled` — the workspace's AI writing-assistance setting is
          off. Reported by `GET /api/v1/ideas/config` as `ai_assist_enabled: false`,
          so hide the sparkles control rather than discovering this by tapping.
        * `forbidden` — AI assist is on, but this caller is outside the workspace's
          submit audience. The same gate `POST /api/v1/ideas` applies, so anyone who
          cannot post an idea cannot draft one either.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  nullable: true
                  description: The idea title typed so far. Optional.
                  example: Dark mode for the mobile app
                description:
                  type: string
                  nullable: true
                  description: The notes the author has already written, used as the
                    seed for the draft. Takes precedence over `notes`.
                  example: hard to read on the floor at night
                notes:
                  type: string
                  nullable: true
                  description: Alias for `description`, read only when `description`
                    is absent or blank. Present because the web form posts this name.
      responses:
        '200':
          description: A draft description. Nothing was created or modified.
          content:
            application/json:
              schema:
                type: object
                required:
                - draft
                - prior_text
                properties:
                  draft:
                    type: object
                    required:
                    - text
                    - fallback
                    - notice
                    properties:
                      text:
                        type: string
                        description: The drafted description, for the description
                          field.
                        example: On night shift the screen is hard to read…
                      fallback:
                        type: boolean
                        description: True when the LLM was unavailable and `text`
                          is a locally composed outline rather than an AI draft. Badge
                          it.
                        example: false
                      notice:
                        type: string
                        description: The message to show alongside the draft. Differs
                          by `fallback` — do not hardcode either wording client-side.
                        example: Drafted with AI — edit freely before posting.
                  prior_text:
                    type: string
                    description: The notes that were sent in, echoed back so a client
                      can offer to restore them. Empty string when nothing was sent.
                    example: hard to read on the floor at night
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: AI writing assistance is off for the workspace (error code
            `ai_assist_disabled`), the caller is outside the submit audience (`forbidden`),
            or the Ideas app is not accessible to them (`access_denied`).
  "/ideas/list":
    get:
      tags:
      - Ideas
      summary: Ideas feed ("All Ideas")
      description: |
        The native-client mirror of the web "All Ideas" feed
        (Apps::Ideas::IdeasController#index). A paginated, filtered, ordered list
        of ideas backed by the SAME query object the web uses
        (::Ideas::FeedListQuery), so the two surfaces can't drift.

        **Filter** (`filter`, one flat dimension, default `all`):
        * `all` — every idea in the business.
        * `my`  — ideas the caller authored (no stage sub-filter; the web's
          per-stage "My Ideas" facet is intentionally omitted here).
        * `<stage_id>` — every idea in that lifecycle stage. Use a `stage_id`
          from `counts.by_stage`. An unknown/foreign value falls back to `all`.

        **Sort** (`sort`, default `top`; the second level is always `created_at`
        DESC, with `id` DESC as a stable tiebreak):
        * `top`       — Most Voted on Top (`vote_count` desc)
        * `myvoted`   — My Votes on Top (ideas the caller upvoted first)
        * `new`       — Newest on Top (`created_at` desc)
        * `discussed` — Most Discussed on Top (comment count desc)
        * `score`     — Highest Score on Top (`rice_score` desc). When RICE
          scoring is turned off for the workspace this degrades to `top`
          (mirroring the web, which hides the option), and `sort` echoes `top`.

        **Counts** are filter-blind — `counts.all`, `counts.my` and one
        `counts.by_stage` row per lifecycle stage (position order, count 0 when
        empty) always reflect the whole business, so the client can badge every
        filter chip. `meta.total_count`, by contrast, reflects the ACTIVE filter
        (it is what pagination is over).

        **Description** — every row carries `description`, the idea's plain-text
        body, on EVERY filter and sort. It is the same field (same column) that
        `GET /ideas/search` and a campaign's idea list return, so one parser handles
        every list surface, and it is NOT truncated — a client renders whatever
        snippet its card design needs. This costs no extra query: the body is a
        plain column on the row the feed already loads.

        **Voting close date** — each idea carries `voting_closes_on` (ISO date)
        ONLY when the tenant has enabled the voting-close-date feature AND that
        idea has a date set; the key is omitted otherwise. Same gate the web uses
        to show the "Voting closes …" pill.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: all (default) | my | <stage_id>. An unknown value falls back
          to all.
        schema:
          type: string
          default: all
          example: all
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - top
          - myvoted
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Ideas feed retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - ideas
                - active_filter
                - sort
                - counts
                - meta
                properties:
                  active_filter:
                    type: string
                    description: The filter actually applied (echoes an unknown value
                      back as `all`).
                    example: all
                  sort:
                    type: string
                    description: The sort actually applied (`score` echoes `top` when
                      scoring is off).
                    example: top
                  ideas:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - description
                      - stage
                      - creator
                      - comments_count
                      - created_at
                      - vote_count
                      - has_voted
                      properties:
                        id:
                          type: integer
                          example: 91
                        title:
                          type: string
                          example: Add SSO for contractors
                        description:
                          type: string
                          nullable: true
                          description: 'The idea''s plain-text body, on EVERY filter
                            (`all` / `my` / `<stage_id>`) and every sort — the same
                            field, from the same column, that `GET /ideas/search`
                            and a campaign''s idea list return. Not truncated: a client
                            renders its own snippet. Null only for an idea whose body
                            is genuinely empty.'
                          example: We should let contractors sign in with SSO.
                        stage:
                          type: object
                          description: The idea's current lifecycle stage.
                          properties:
                            id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            color:
                              type: string
                              description: The stage's whitelist-coerced hex color.
                              example: "#2e63b3"
                        creator:
                          type: object
                          properties:
                            id:
                              type: integer
                              nullable: true
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              description: The author's full name.
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL (a ui-avatars initial
                                tile when the user has no photo).
                              example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                        comments_count:
                          type: integer
                          description: Non-deleted comments + replies.
                          example: 4
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-07-31T06:01:58Z'
                        vote_count:
                          type: integer
                          description: Positive votes (up_votes_count counter cache).
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the calling user has upvoted this idea.
                          example: true
                        rice_score:
                          type: integer
                          nullable: true
                          description: Cached RICE score; null when not scored. OMITTED
                            entirely while RICE scoring is off for the workspace.
                          example: 42
                        rice_score_band:
                          type: object
                          description: 'The colour band `rice_score` renders as, so
                            a client paints the score pill the same way the web does
                            without hardcoding our ranges. `tier` is the stable key
                            to switch on: `high` (>= 700), `medium` (>= 300), `low`
                            below that, `none` when the idea is awaiting its first
                            score. Travels with `rice_score` and is OMITTED under
                            the same gate (RICE scoring off for the workspace).'
                          properties:
                            tier:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
                              - none
                              example: high
                            label:
                              type: string
                              description: Human label for the band.
                              example: High
                            color:
                              type: string
                              description: Foreground hex the web uses.
                              example: "#146c43"
                            background:
                              type: string
                              description: Background hex the web uses; `transparent`
                                for the `none` band.
                              example: "#d1f0e0"
                        voting_closes_on:
                          type: string
                          format: date
                          description: The idea's voting close date (ISO `YYYY-MM-DD`).
                            Present ONLY when the tenant has the voting-close-date
                            feature enabled (`enable_close_date`) AND this idea has
                            a date set — the key is OMITTED otherwise (not null).
                            Mirrors the web, which shows the "Voting closes …" pill
                            under both conditions.
                          example: '2026-08-15'
                  counts:
                    type: object
                    description: Filter-blind badge counts for the whole business.
                    required:
                    - all
                    - my
                    - by_stage
                    properties:
                      all:
                        type: integer
                        description: All ideas in the business.
                        example: 128
                      my:
                        type: integer
                        description: Ideas the caller authored.
                        example: 7
                      by_stage:
                        type: array
                        description: One row per lifecycle stage, in position order
                          (count 0 when empty).
                        items:
                          type: object
                          required:
                          - stage_id
                          - name
                          - count
                          properties:
                            stage_id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            count:
                              type: integer
                              example: 14
                  meta:
                    type: object
                    description: Pagination over the ACTIVE filter.
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the active filter.
                        example: 128
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 7
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/search":
    get:
      tags:
      - Ideas
      summary: Search ideas
      description: |
        Free-text search over the Ideas feed — the native-client mirror of the
        web search box ("Ideas" bottom-nav → search → type). Backed by the SAME
        query object the list + web feed use (`::Ideas::FeedListQuery`, given a
        search term), so search and list agree on matching, ordering, counting
        and preloading.

        **`q`** is matched case-insensitively against the idea **title** and
        **description** (LIKE wildcards in the term are escaped). A blank/absent
        `q` returns the full feed (browse), matching the web, whose empty search
        box shows every idea.

        **Sort** (`sort`, default `top`; second level always `created_at` DESC,
        `id` DESC tiebreak): `top` (Most Voted) | `myvoted` | `new` | `discussed`
        | `score` (RICE; degrades to `top` and echoes `top` when scoring is off).

        Each row is the SAME card the feed serializes PLUS **`description`** (the
        plain-text body). `voting_closes_on` is present ONLY when the tenant has
        the voting-close-date feature enabled AND that idea has a date set.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Search term (title + description, case-insensitive). Blank returns
          the full feed.
        schema:
          type: string
          example: single sign-on
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - top
          - myvoted
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Search results retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - ideas
                - query
                - sort
                - meta
                properties:
                  query:
                    type: string
                    description: The search term actually applied (echoes `q`, trimmed).
                    example: single sign-on
                  sort:
                    type: string
                    description: The sort actually applied (`score` echoes `top` when
                      scoring is off).
                    example: top
                  ideas:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - description
                      - stage
                      - creator
                      - comments_count
                      - created_at
                      - vote_count
                      - has_voted
                      properties:
                        id:
                          type: integer
                          example: 91
                        title:
                          type: string
                          example: Add SSO for contractors
                        description:
                          type: string
                          nullable: true
                          description: The idea's plain-text body.
                          example: We should let contractors sign in with SSO.
                        stage:
                          type: object
                          description: The idea's current lifecycle stage.
                          properties:
                            id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            color:
                              type: string
                              description: The stage's whitelist-coerced hex color.
                              example: "#2e63b3"
                        creator:
                          type: object
                          properties:
                            id:
                              type: integer
                              nullable: true
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              description: The author's full name.
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL (a ui-avatars initial
                                tile when the user has no photo).
                              example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                        comments_count:
                          type: integer
                          description: Non-deleted comments + replies.
                          example: 4
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-07-31T06:01:58Z'
                        vote_count:
                          type: integer
                          description: Positive votes (up_votes_count counter cache).
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the calling user has upvoted this idea.
                          example: true
                        rice_score:
                          type: integer
                          nullable: true
                          description: Cached RICE score (the idea's score); null
                            when not scored. OMITTED entirely while RICE scoring is
                            off for the workspace.
                          example: 42
                        rice_score_band:
                          type: object
                          description: 'The colour band `rice_score` renders as, so
                            a client paints the score pill the same way the web does
                            without hardcoding our ranges. `tier` is the stable key
                            to switch on: `high` (>= 700), `medium` (>= 300), `low`
                            below that, `none` when the idea is awaiting its first
                            score. Travels with `rice_score` and is OMITTED under
                            the same gate (RICE scoring off for the workspace).'
                          properties:
                            tier:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
                              - none
                              example: high
                            label:
                              type: string
                              description: Human label for the band.
                              example: High
                            color:
                              type: string
                              description: Foreground hex the web uses.
                              example: "#146c43"
                            background:
                              type: string
                              description: Background hex the web uses; `transparent`
                                for the `none` band.
                              example: "#d1f0e0"
                        voting_closes_on:
                          type: string
                          format: date
                          description: The idea's voting close date (ISO `YYYY-MM-DD`).
                            Present ONLY when the tenant has the voting-close-date
                            feature enabled (`enable_close_date`) AND this idea has
                            a date set — the key is OMITTED otherwise (not null).
                          example: '2026-08-15'
                  meta:
                    type: object
                    description: Pagination over the matching set.
                    properties:
                      total_count:
                        type: integer
                        description: Ideas matching the search.
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/review_queue":
    get:
      tags:
      - Ideas
      summary: My Review Queue
      description: |
        The native-client mirror of the web "My Review Queue"
        (Apps::Ideas::ReviewQueueController#index queue tab): the ideas routed to
        a review panel the caller belongs to, ranked by RICE priority. Backed by
        the SAME query object semantics the web uses (::Ideas::ReviewQueueQuery),
        so the two surfaces can't drift.

        **Panel membership is the only grant** — admins get no bypass. A caller on
        no review panel receives `on_panel: false` with an empty list and zeroed
        counts (HTTP 200, NOT 403), mirroring the web tab's empty state; the client
        should hide the Review tab for such users (see
        `GET /api/v1/apps?include_navigation=true`, which only exposes the Reviews
        item to reviewers).

        **Campaign filter** (`campaign`, default `all`):
        * `all`  — every idea in the caller's accessible set.
        * `none` — only ideas not attached to any campaign.
        * `<id>` — only that campaign's ideas. Use an `id` from `campaigns`
          (the campaigns represented in the caller's accessible set). A
          non-numeric value falls back to `all`.

        **Awaiting-score sub-filter** (`needs`): when truthy, only ideas that have
        no RICE score yet (awaiting their first score) are returned. Ignored when
        RICE scoring is turned off for the workspace.

        **`needs_scoring_count`** badges the "awaiting score" chip: the number of
        unscored ideas WITHIN the active campaign filter. It is measured on the
        campaign-filtered set (not the `needs`-filtered one), so it stays stable
        while the toggle is on. Zero when scoring is off.

        Ordering is RICE `rice_score` DESC (unscored ideas sink to the bottom),
        then `created_at` DESC, then `id` DESC as a stable tiebreak.
        `meta.total_count` reflects the ACTIVE filters (it is what pagination is
        over).
      security:
      - BearerAuth: []
      parameters:
      - name: campaign
        in: query
        required: false
        description: all (default) | none (ideas not in any campaign) | <campaign_id>.
          A non-numeric value falls back to all.
        schema:
          type: string
          default: all
          example: none
      - name: needs
        in: query
        required: false
        description: |-
          Only ideas awaiting their first RICE score. Ignored when scoring is disabled.
          Declared as a boolean, and it now BEHAVES as one: only `1`, `t`, `true` or `on` turns the filter on, and every other value — including the `false` / `0` a generated client emits for an unchecked chip — leaves it off, exactly as omitting the key does. (Before 2026-09-10 the value was read for mere presence, so `needs=false` switched the filter ON and a reviewer who unchecked "Awaiting score" watched their already-scored items disappear.) Same true-spelling allowlist as this API's `with_ideas` and `open_for_submissions`.
        schema:
          type: boolean
          example: true
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Review queue retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - on_panel
                - ideas
                - active_campaign
                - needs_score
                - needs_scoring_count
                - campaigns
                - meta
                properties:
                  on_panel:
                    type: boolean
                    description: Whether the caller belongs to any review panel. When
                      false, `ideas` is empty and the counts are zero.
                    example: true
                  active_campaign:
                    type: string
                    description: The campaign filter actually applied (`all` | `none`
                      | `<id>`; echoes an unknown value back as `all`).
                    example: none
                  needs_score:
                    type: boolean
                    description: Whether the awaiting-score sub-filter is active (always
                      false when scoring is off).
                    example: false
                  needs_scoring_count:
                    type: integer
                    description: Ideas awaiting their first score within the active
                      campaign filter (stable while `needs` is on; 0 when scoring
                      is off).
                    example: 8
                  ideas:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - display_id
                      - title
                      - stage
                      - creator
                      - comments_count
                      - created_at
                      - vote_count
                      - has_voted
                      - description
                      properties:
                        id:
                          type: integer
                          example: 91
                        display_id:
                          type: string
                          description: The human reference shown on the web ("ID-<n>").
                          example: ID-91
                        title:
                          type: string
                          example: Add SSO for contractors
                        stage:
                          type: object
                          description: The idea's current lifecycle stage.
                          properties:
                            id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            color:
                              type: string
                              description: The stage's whitelist-coerced hex color.
                              example: "#2e63b3"
                        creator:
                          type: object
                          properties:
                            id:
                              type: integer
                              nullable: true
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              description: The author's full name.
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL (a ui-avatars initial
                                tile when the user has no photo).
                              example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                        comments_count:
                          type: integer
                          description: Non-deleted comments + replies.
                          example: 4
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-07-31T06:01:58Z'
                        vote_count:
                          type: integer
                          description: Positive votes (up_votes_count counter cache).
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the calling user has upvoted this idea.
                          example: true
                        rice_score:
                          type: integer
                          nullable: true
                          description: Cached RICE score; null when the idea is awaiting
                            its first score. OMITTED entirely while RICE scoring is
                            off for the workspace.
                          example: 42
                        rice_score_band:
                          type: object
                          description: 'The colour band `rice_score` renders as, so
                            a client paints the score pill the same way the web does
                            without hardcoding our ranges. `tier` is the stable key
                            to switch on: `high` (>= 700), `medium` (>= 300), `low`
                            below that, `none` when the idea is awaiting its first
                            score. Travels with `rice_score` and is OMITTED under
                            the same gate (RICE scoring off for the workspace).'
                          properties:
                            tier:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
                              - none
                              example: high
                            label:
                              type: string
                              description: Human label for the band.
                              example: High
                            color:
                              type: string
                              description: Foreground hex the web uses.
                              example: "#146c43"
                            background:
                              type: string
                              description: Background hex the web uses; `transparent`
                                for the `none` band.
                              example: "#d1f0e0"
                        description:
                          type: string
                          nullable: true
                          description: The idea's plain-text description (description_text);
                            null for legacy ideas saved before a description was required.
                          example: Contractors need SSO so we can offboard them centrally.
                        voting_closes_on:
                          type: string
                          format: date
                          description: The idea's voting close date (ISO `YYYY-MM-DD`).
                            Present ONLY when the tenant has the voting-close-date
                            feature enabled (`enable_close_date`) AND this idea has
                            a date set — the key is OMITTED otherwise (not null).
                            Mirrors the web, which shows the "Voting closes …" pill
                            under both conditions.
                          example: '2026-08-15'
                  campaigns:
                    type: array
                    description: The campaigns represented in the caller's accessible
                      set — the options for the `campaign` filter.
                    items:
                      type: object
                      required:
                      - id
                      - title
                      properties:
                        id:
                          type: integer
                          example: 4
                        title:
                          type: string
                          example: Q4 Cost Savings
                  meta:
                    type: object
                    description: Pagination over the ACTIVE filters.
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the active filters.
                        example: 23
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 2
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/campaigns":
    get:
      tags:
      - Ideas
      summary: Idea campaigns list + search
      description: |
        The native-client mirror of the web Campaigns index
        (`Apps::Ideas::CampaignsController#index`). A paginated, phase-filtered list
        of campaigns backed by the SAME query object and phase SQL the web uses
        (`Ideas::CampaignListQuery` + `Campaign.phase_sql`), so the two surfaces
        can't drift.

        **Requires campaigns to be enabled.** The whole endpoint is gated on the
        Ideas `campaigns` setting (on unless an admin turns it off). When it is off
        the response is `403` with error code `campaigns_disabled` — distinct from
        the app-access `403 access_denied` — so a client can hide the Campaigns tab
        rather than showing it and failing on tap.

        **`status` is the DERIVED phase**, not the stored `status` enum (which only
        has open/closed and would report a future-dated campaign as "open"):
        * `scheduled` — the start date is in the future.
        * `closed` — manually closed, or the close date has passed.
        * `open` — everything else (a campaign with no close date is open-ended).

        **Filter** (`filter`, default `all`): `all` | `open` | `scheduled` |
        `closed`. An unknown value falls back to `all`.

        **Search** (`q`, optional): a case-insensitive substring matched against the
        campaign **title OR description (brief)** — the same `Campaign.search`
        predicate the web index uses, so a term returns the same campaigns on both
        surfaces. A mid-word slice matches (`ffice` finds "Green Office"); `%` and
        `_` are matched literally rather than as wildcards. Blank/absent is a no-op.
        The applied term is echoed back as `query` (trimmed, `""` when not
        searching) so a client can render "N campaigns for “term”".

        Search composes with `filter` and with pagination, and it narrows the
        `counts` as well as the rows — see **Counts** below. A term that matches
        nothing is a normal `200` with an empty `campaigns` array and zeroed
        counts, never an error.

        **Order** is the per-tab order the web uses: Open → closing soonest first
        (open-ended last), Scheduled → opening soonest first, Closed → most recently
        ended first, All → title A–Z. Every order ends in a title + id tiebreak, so
        paging never repeats or skips a row.

        **Review panel** (`reviewers`, per row) is the panel that campaign's ideas
        route to: its own group when it names one, else the **workspace default**. It
        carries the group (`id` + `name`), the panel's FULL member `count`, and up to
        **3** `members` for an avatar stack. Three things worth reading twice:

        * `count` is the panel's real size, **not** `members.size` — the preview caps
          at 3, so a client rendering "N reviewers" off the array would say 3 for a
          40-person panel.
        * `members` is ordered by user id ascending, which makes it exactly the first
          page of `GET /ideas/campaigns/{id}/reviewers` — the card and the roster it
          opens cannot disagree. Use that endpoint for the paginated, searchable list.
        * `members` is **omitted entirely** while the `reviewer_names` setting is off
          (`group` and `count` still ship). Same withholding the campaign detail, the
          idea detail and the roster endpoint apply, so a tenant that hides reviewer
          identities hides them here too.

        A stale `reviewer_group_id`, or one belonging to another tenant, reads as
        **unset** (`group: null`, `count: 0`) rather than leaking a foreign group name.

        **Counts** are filter-blind but search-scoped. `counts.all` / `open` /
        `scheduled` / `closed` let the client badge every chip without re-fetching,
        and `all` is exactly the sum of the three phases. They describe the whole
        business when not searching, and the MATCHING campaigns when `q` is present
        (so each chip badges what tapping it actually returns). `meta.total_count`,
        by contrast, reflects the ACTIVE filter — it is what pagination is over.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: Derived-phase filter. An unknown value falls back to `all`.
        schema:
          type: string
          enum:
          - all
          - open
          - scheduled
          - closed
          default: all
      - name: q
        in: query
        required: false
        description: Search term — case-insensitive substring matched against the
          campaign title OR description (brief). Matches mid-word; `%` and `_` are
          literal, not wildcards. Blank/absent disables the search. Narrows `counts`
          as well as the rows, and composes with `filter` + pagination.
        schema:
          type: string
          example: savings
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Campaigns retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaigns
                - active_filter
                - query
                - counts
                - meta
                properties:
                  active_filter:
                    type: string
                    description: The filter actually applied (echoes an unknown value
                      back as `all`).
                    example: all
                  query:
                    type: string
                    description: The search term actually applied, trimmed. Empty
                      string when not searching. Echoed so a client can label the
                      results ("N campaigns for “term”").
                    example: savings
                  campaigns:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - description
                      - status
                      - start_date
                      - end_date
                      - icon
                      - color
                      - color_hex
                      - has_my_idea
                      - ideas_count
                      - reviewers
                      properties:
                        id:
                          type: integer
                          example: 12
                        title:
                          type: string
                          example: Q4 Cost Savings
                        description:
                          type: string
                          nullable: true
                          description: The campaign brief.
                          example: Ideas to cut operating cost this quarter.
                        status:
                          type: string
                          description: The DERIVED phase (see above) — not the stored
                            status enum.
                          enum:
                          - open
                          - scheduled
                          - closed
                          example: open
                        start_date:
                          type: string
                          format: date
                          nullable: true
                          description: ISO-8601 date; null when the campaign has no
                            start date.
                          example: '2026-07-21'
                        end_date:
                          type: string
                          format: date
                          nullable: true
                          description: ISO-8601 date; null when the campaign is open-ended.
                          example: '2026-08-20'
                        icon:
                          type: string
                          description: Font Awesome glyph name with no `fa-` prefix
                          coerced to the authored set (default `bullhorn`).:
                          example: rocket
                        color:
                          type: string
                          description: Palette key
                          whitelist-coerced (default `blue`).:
                          enum:
                          - blue
                          - indigo
                          - purple
                          - magenta
                          - red
                          - sunset
                          - amber
                          - forest
                          - teal
                          - slate
                          example: forest
                        color_hex:
                          type: string
                          description: The palette key's primary hex
                          so a client can render without shipping the palette.:
                          example: "#2d6a4f"
                        has_my_idea:
                          type: boolean
                          description: Whether the CALLING user has authored at least
                            one idea in this campaign.
                          example: true
                        ideas_count:
                          type: integer
                          description: Ideas submitted to this campaign.
                          example: 2
                        reviewers:
                          type: object
                          description: The review panel this campaign's ideas route
                            to — its own group when it names one, else the workspace
                            default panel (the same fallback the web card and the
                            campaign detail apply, so a card can never name a different
                            panel than the detail it opens).
                          required:
                          - group
                          - count
                          properties:
                            group:
                              type: object
                              nullable: true
                              description: "`null` (with `count: 0`) when the campaign
                                names no panel AND the workspace has no default —
                                and also when its saved `reviewer_group_id` is stale
                                or belongs to another tenant, which reads as unset
                                rather than leaking the foreign group's name."
                              required:
                              - id
                              - name
                              properties:
                                id:
                                  type: integer
                                  example: 44387
                                name:
                                  type: string
                                  example: Product Council
                            count:
                              type: integer
                              description: The panel's FULL member count — **not**
                                `members.size`. The preview is capped at 3, so rendering
                                "N reviewers" off the array length would report 3
                                for a 40-person panel.
                              example: 12
                            members:
                              type: array
                              maxItems: 3
                              description: Up to **3** panel members for an avatar
                                stack, ordered by user id ascending — the same leading
                                slice `GET /ideas/campaigns/{id}/reviewers` returns
                                on page 1, so a card's avatars and the roster screen
                                it opens agree. **OMITTED entirely** while the `reviewer_names`
                                setting is off (`group` and `count` still ship, so
                                a card can say "Reviewed by <panel> (N)" without naming
                                anyone). Use the roster endpoint for the paginated,
                                searchable full list.
                              items:
                                type: object
                                required:
                                - id
                                - name
                                - photo
                                properties:
                                  id:
                                    type: integer
                                    example: 63
                                  name:
                                    type: string
                                    nullable: true
                                    example: Grace Dalton
                                  photo:
                                    type: string
                                    description: Absolute avatar URL; never null (falls
                                      back to a generated initials image).
                                    example: https://…/avatar.jpg
                  counts:
                    type: object
                    description: 'Filter-blind but search-scoped badge counts: the
                      whole business when not searching, the MATCHING campaigns when
                      `q` is present. `all` is the sum of the three phases. All zero
                      when a search matches nothing.'
                    required:
                    - all
                    - open
                    - scheduled
                    - closed
                    properties:
                      all:
                        type: integer
                        example: 8
                      open:
                        type: integer
                        example: 3
                      scheduled:
                        type: integer
                        example: 2
                      closed:
                        type: integer
                        example: 3
                  meta:
                    type: object
                    description: Pagination over the ACTIVE filter (and search, when
                      `q` is present).
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the active filter + search.
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            or campaigns are turned off for the workspace (error code `campaigns_disabled`).
    post:
      tags:
      - Ideas
      summary: Launch an idea campaign
      description: |
        Creates a campaign — the native mirror of the web **+ New Campaign** form
        (`Apps::Ideas::CampaignsController#create`), sharing
        `Ideas::CampaignCreationService` so the two surfaces produce identical rows.

        Only **`title`** and **`brief`** are required. Everything else has the same
        default the web form pre-fills, so the minimal request creates exactly what
        the web would.

        **The campaign is created open**, but `status` on every campaign response is
        the DERIVED phase (`Campaign#phase`), not the stored enum — so a campaign
        posted with a future `start_date` comes back `scheduled`, and one with a past
        `close_date` comes back `closed`. That is the same derivation the list, its
        filters and its counts use.

        **Values a browser form could never get wrong are validated here**, because a
        native client has no radio buttons or date pickers to constrain it:
        * `icon` / `color` must be from the authored sets (see the enums below). An
          unknown value is a `422`, not a silent default — storing a glyph the reader
          would coerce away shows the creator an icon they never picked. Omit either
          and you get the web form's default (`bullhorn` / `blue`).
        * dates are parsed strictly as ISO-8601 `YYYY-MM-DD` (what the web's
          `date_field` posts). `01/09/2026` is a `422`, deliberately: reading it
          leniently would book the campaign in a month the client never stated.
        * `reviewer_group_id` must name a group in **this** business — an id from
          another tenant is a `422`, never a campaign routed to a foreign panel.

        **`reviewer_group_id` has three distinct behaviours**, matching the web form:
        * **omitted** — the workspace's configured campaign panel
          (`campaign_reviewer_group_id`, reported by `GET /ideas/config` as
          `campaign_reviewers` / `campaign_options.default_reviewer_group_id`) is
          applied. This is what the web form pre-selects, so a client that renders no
          picker still lands where the web would.
        * **sent empty** (`""` or `null`) — no panel is stored, and the campaign
          resolves to the workspace default panel at read time. This is the web's
          *"— Workspace default —"* option.
        * **an id** — that panel reviews this campaign's ideas.

        **Authorization** is the workspace's *"Who can create campaigns"* audience
        (`campaign_creators`) — everyone, or only one configured group, with no admin
        bypass. Reading campaigns is not enough (`403 forbidden`). Campaigns being
        turned off for the workspace answers the broader `403 campaigns_disabled`
        first, so a client hides the whole tab rather than the one affordance. Check
        `can_create_campaign` on `GET /ideas/config` to decide whether to show the
        "+" at all.

        **`GET /ideas/config` → `campaign_options`** carries the composer's option
        data (the icon set, the palette with hex values, and this workspace's
        selectable reviewer groups), so a native New Campaign screen can only offer
        values this endpoint accepts.

        Responds **201** with the SAME canonical campaign object
        `GET /api/v1/ideas/campaigns/{id}` returns — one query object and one
        serializer back both — so a client can push straight to the detail screen
        without a second call. `ideas` is empty and `ideas_count` is `0` on a fresh
        campaign.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - brief
              properties:
                title:
                  type: string
                  maxLength: 120
                  description: Required. Surrounding whitespace is stripped.
                  example: Mobile Experience 2026
                brief:
                  type: string
                  description: Required — what the campaign is about and what kind
                    of ideas it wants. This is the text every campaign response returns
                    as `description`; that name is accepted here too (as an alias),
                    so a client can post back the object it just read without renaming
                    a key. `brief` wins when both are sent, and an explicitly blank
                    `brief` is a `422` rather than silently falling through to `description`.
                  example: Ideas to make the frontline mobile app faster and simpler.
                description:
                  type: string
                  description: Alias for `brief` (see above). Ignored when `brief`
                    is present.
                icon:
                  type: string
                  description: Optional Font Awesome glyph name with no `fa-` prefix.
                    Defaults to `bullhorn` (the web form's default). An unlisted value
                    is a `422` `invalid_icon`.
                  enum:
                  - bullhorn
                  - mobile-screen-button
                  - user-plus
                  - wand-magic-sparkles
                  - shield-halved
                  - rocket
                  - lightbulb
                  - bolt
                  - heart
                  - leaf
                  - chart-line
                  - gear
                  - calendar-days
                  - clipboard-check
                  - comments
                  - headset
                  - store
                  - truck
                  - users
                  - star
                  - flag
                  - graduation-cap
                  - globe
                  - briefcase
                  default: bullhorn
                  example: rocket
                color:
                  type: string
                  description: Optional palette key for the campaign's card and chips.
                    Defaults to `blue`. An unlisted value is a `422` `invalid_color`.
                    The response echoes both the key (`color`) and its primary hex
                    (`color_hex`).
                  enum:
                  - blue
                  - indigo
                  - purple
                  - magenta
                  - red
                  - sunset
                  - amber
                  - forest
                  - teal
                  - slate
                  default: blue
                  example: forest
                start_date:
                  type: string
                  format: date
                  nullable: true
                  description: Optional ISO-8601 `YYYY-MM-DD`. Submissions open on
                    this date — a future value makes the campaign `scheduled`. Also
                    accepted as `start_on` (the web's own field name). Anything that
                    isn't a well-formed ISO day is a `422` `invalid_date`.
                  example: '2026-09-01'
                close_date:
                  type: string
                  format: date
                  nullable: true
                  description: Optional ISO-8601 `YYYY-MM-DD`, and must be on or after
                    `start_date` (else `422 invalid`). Submissions close automatically
                    on this date. Also accepted as `end_date` (the key the response
                    returns it under) and `close_on` (the web's field name). Leave
                    both dates blank for an open-ended campaign.
                  example: '2026-09-30'
                reviewer_group_id:
                  type: integer
                  nullable: true
                  description: Optional. The panel that manages the review lifecycle
                    for this campaign's ideas. Omit the key to inherit the workspace's
                    configured campaign panel; send it empty/null to store no panel
                    (resolving to the workspace default at read time); send an id
                    from `GET /ideas/config` → `campaign_options.reviewer_groups`.
                    An id outside this business is a `422` `invalid_reviewer_group`.
                  example: 7
      responses:
        '201':
          description: Campaign created
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                properties:
                  campaign:
                    type: object
                    description: The created campaign, in the SAME shape `GET /api/v1/ideas/campaigns/{id}`
                      returns — see that endpoint for the full field list. `ideas`
                      is `[]`, `ideas_count` is `0` and `has_my_idea` is `false` on
                      a fresh campaign.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Mobile Experience 2026
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                      status:
                        type: string
                        description: The DERIVED phase, not the stored enum.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-09-01'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-09-30'
                      icon:
                        type: string
                        example: rocket
                      color:
                        type: string
                        example: forest
                      color_hex:
                        type: string
                        example: "#2d6a4f"
                      ideas_count:
                        type: integer
                        example: 0
                      has_my_idea:
                        type: boolean
                        example: false
                      creator:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Ada Adminson
                          photo:
                            type: string
                            nullable: true
                      reviewers:
                        type: object
                        description: 'The panel this campaign routes to (its own group,
                          else the workspace default): its `group.name`, the panel''s
                          FULL `count`, and an avatar preview of at most 3 `members`.
                          `members` is withheld while the "Show reviewer names" setting
                          is off.'
                        properties:
                          group:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 7
                              name:
                                type: string
                                example: Product Council
                          count:
                            type: integer
                            description: The panel's full size
                            not the preview length.:
                            example: 8
                          members:
                            type: array
                            maxItems: 3
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                      can_edit:
                        type: boolean
                        description: Whether the CALLER may manage the campaign they
                          just created — always `true` here (they are its creator),
                          and the same flag `GET /ideas/campaigns/{id}` reports.
                        example: true
                      can_delete:
                        type: boolean
                        description: Always equal to `can_edit` (one manage right).
                        example: true
                      ideas:
                        type: array
                        description: Always empty on create.
                        items:
                          type: object
                      ideas_meta:
                        type: object
                        properties:
                          total_count:
                            type: integer
                            example: 0
                          current_page:
                            type: integer
                            example: 1
                          per_page:
                            type: integer
                            example: 50
                          total_pages:
                            type: integer
                            example: 0
                          sort:
                            type: string
                            example: top
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Campaigns are turned off for the workspace (error code `campaigns_disabled`,
            checked first), the caller is outside the `campaign_creators` audience
            (`forbidden`), or the Ideas app is not accessible to them (`access_denied`).
        '422':
          description: 'The campaign was not created. `error.code` says why, and `error.message`
            is the sentence to show: `invalid` — a missing/blank title or brief, a
            title over 120 characters, or a close date before the start date; `invalid_icon`
            / `invalid_color` — a value outside the authored set (the message lists
            the valid ones); `invalid_date` — a date that isn''t a well-formed ISO-8601
            `YYYY-MM-DD`; `invalid_reviewer_group` — a group id that doesn''t belong
            to this workspace; `create_failed` — the write itself failed; retry. Also
            `content_blocked` when the workspace''s content-moderation policy refuses
            the submitted text; the message is the policy''s own and is safe to show
            the author verbatim.'
  "/ideas/campaigns/ai_brief":
    post:
      tags:
      - Ideas
      summary: Draft a campaign brief with AI
      description: |
        The native mirror of the web's **"Draft with AI"** sparkles button beside the
        Brief field on the New/Edit Campaign form
        (`Apps::Ideas::CampaignsController#ai_brief`). Both surfaces call
        `Ideas::AiDraftService#draft_campaign_brief`.

        **Writes nothing and launches no campaign.** It returns text for the Brief
        field; launching is still `POST /ideas/campaigns`. Safe to call repeatedly —
        each call re-drafts.

        **`title` is optional and may be blank.** The web button carries
        `formnovalidate` so an author can ask for a brief before committing to a
        title, and the service drafts about "this topic" when it gets nothing. A blank
        title is a `200` with usable text, never a `422`.

        See `POST /ideas/ai_draft` for why `fallback` and `notice` are part of the
        contract rather than decoration — the shape is identical.

        **Access** — the workspace `ai_assist` setting must be on (`403`
        `ai_assist_disabled`), campaigns must be enabled for the workspace (`403`
        `campaigns_disabled`), and the caller must be able to reach a campaign FORM on
        the web: the `campaign_creators` audience (the New screen) **or** an Ideas
        admin — the Edit screen's `require_campaign_manager` admits an admin who sits
        outside that audience, so gating on the audience alone would 403 them out of
        the button their own Edit screen renders. `GET /ideas/config` reports
        `ai_assist`, `campaigns_enabled` and `can_manage_campaigns` up front.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  description: The campaign title typed so far. Optional; may be blank.
                  example: Q4 Cost Savings
      responses:
        '200':
          description: A draft. Nothing was written.
          content:
            application/json:
              schema:
                type: object
                properties:
                  draft:
                    type: object
                    properties:
                      text:
                        type: string
                        description: The drafted brief, ready to place in the Brief
                          field.
                      fallback:
                        type: boolean
                        description: True when the LLM was unavailable and this is
                          the LOCAL outline. Badge it differently — do not present
                          it as "Drafted with AI".
                        example: false
                      notice:
                        type: string
                        description: The banner copy for whichever case applied —
                          the web's exact string.
                        example: Drafted with AI — edit anything before launching.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            campaigns are turned off for the workspace (error code `campaigns_disabled`),
            AI writing assistance is turned off (error code `ai_assist_disabled`),
            or the caller may not author a campaign (error code `forbidden`).
  "/ideas/campaigns/lite":
    get:
      tags:
      - Ideas
      summary: Idea campaigns picker (lite)
      description: |
        The stripped-down twin of `GET /ideas/campaigns`: just `{ id, name }` per
        campaign. Built for a campaign filter/dropdown, where the full card payload is
        wasted bytes.

        **`with_ideas` chooses the set** (default `true`):

        * `with_ideas=true` (or the parameter omitted) — only campaigns that hold
          **at least one idea**. Use this for a FILTER over existing ideas (the feed
          filter, the review queue), where an empty campaign is a dead option that
          would return nothing. A campaign appears the moment its first idea is
          submitted and drops out when its last idea is deleted or detached.
        * `with_ideas=false` — **every** campaign in the workspace, empty ones
          included. Use this for a DESTINATION picker (submitting an idea to a
          campaign, moving one), because a campaign is empty precisely until someone
          submits the first idea to it — filtering empties out would hide the campaign
          an admin just launched.

        `true` is the default because it is what this endpoint returned before the
        parameter existed, so a client that sends nothing sees no change. Only the
        standard false spellings (`false`, `0`, `f`, `off`) turn it off; any other
        value — including a blank `?with_ideas=` — reads as `true`, so a malformed
        request never silently widens the list. The applied value is echoed back as
        `with_ideas`.

        **`open_for_submissions` restricts to submittable campaigns** (default
        `false`):

        * `open_for_submissions=false` (or the parameter omitted) — **every phase**:
          open, scheduled and closed alike. A closed campaign's ideas still exist and
          are still a legitimate thing to filter to, so a FILTER must be offered it.
        * `open_for_submissions=true` — only campaigns an idea can actually be
          submitted to. This is the native mirror of the **web "Post an Idea" campaign
          dropdown**: both surfaces read the same
          `::Ideas::Campaign.open_for_submissions` scope, so they cannot offer a
          different option set for the same workspace. It excludes a manually closed
          campaign, a **scheduled** one (its start date is still in the future) and one
          whose **close date has passed** — note the last two are still `status: open`
          on the campaign list, and all three are rejected on submission with
          `campaign is not open for submissions` (`422`), so offering them in a
          submission picker offers a guaranteed failure. A campaign starting today or
          closing today is still open.

        Default `false` for backwards compatibility. Symmetrically to `with_ideas`,
        only the standard true spellings (`true`, `1`, `t`, `on`) turn it on; any other
        value — including a blank `?open_for_submissions=` — reads as `false`, so a
        malformed request never silently *narrows* the list. The applied value is
        echoed back as `open_for_submissions`.

        > ⚠️ **`open_for_submissions=true` flips the `with_ideas` default to `false`.**
        > A campaign holds no ideas precisely until someone submits the first one to
        > it, so intersecting both defaults would hide the campaign an admin just
        > launched — exactly the failure the `with_ideas` note above warns about. Send
        > `with_ideas` explicitly to override this in either direction; every
        > combination stays reachable, and both applied values come back in the
        > response.

        * `name` is the campaign **title** (the same string `title` carries on the
          full list).
        * **Unpaginated by design** — a picker needs the whole option set in one
          call, so there is no `meta` envelope and no `page` / `per_page` parameter.
          Note that `with_ideas=false` therefore returns every campaign the workspace
          has ever created, which in a long-lived tenant is a larger payload than the
          filtered list.
        * Ordered by name, A–Z (case-insensitive), with an id tiebreak so the list is
          stable across calls. Identical in every mode.

        **Requires campaigns to be enabled**, exactly like the full list: when the
        Ideas `campaigns` setting is off the response is `403` with error code
        `campaigns_disabled`, distinct from the app-access `403 access_denied`.
      parameters:
      - name: with_ideas
        in: query
        required: false
        description: "`true` (default) returns only campaigns holding at least one
          idea; `false` returns every campaign. Recognised false values are `false`,
          `0`, `f` and `off` — anything else, including a blank value, is treated
          as `true`. The default becomes `false` when `open_for_submissions=true`;
          send this parameter explicitly to override that."
        schema:
          type: boolean
          default: true
      - name: open_for_submissions
        in: query
        required: false
        description: '`true` returns only campaigns an idea can actually be submitted
          to — the same set the web "Post an Idea" dropdown offers, excluding manually
          closed, scheduled (future start date) and past-close-date campaigns. `false`
          (default) returns every phase. Recognised true values are `true`, `1`, `t`
          and `on` — anything else, including a blank value, is treated as `false`.
          Setting it to `true` also flips the `with_ideas` default to `false`.'
        schema:
          type: boolean
          default: false
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Picker list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaigns
                - with_ideas
                - open_for_submissions
                properties:
                  campaigns:
                    type: array
                    description: The matching campaigns, name-ascending — narrowed
                      to those holding at least one idea when `with_ideas` is true,
                      and to those open for submissions when `open_for_submissions`
                      is true. Empty when the workspace has nothing to offer.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      properties:
                        id:
                          type: integer
                          example: 12
                        name:
                          type: string
                          description: The campaign title.
                          example: Q4 Cost Savings
                  with_ideas:
                    type: boolean
                    description: The emptiness filter actually applied — echoed so
                      a client can confirm which set it received. True when the parameter
                      was omitted, blank or unrecognised, EXCEPT under `open_for_submissions=true`,
                      where the default is false.
                    example: true
                  open_for_submissions:
                    type: boolean
                    description: The phase filter actually applied — echoed for the
                      same reason. False when the parameter was omitted, blank or
                      unrecognised.
                    example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            or campaigns are turned off for the workspace (error code `campaigns_disabled`).
  "/ideas/campaigns/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: One campaign's detail
      description: |
        A single campaign with its creator, its review panel, and a PAGINATED page of
        its ideas. The native mirror of the web campaign detail
        (`Apps::Ideas::CampaignsController#show`), backed by the shared
        `::Ideas::CampaignDetailQuery` — so the idea ordering, the reviewer-panel
        fallback and the totals cannot drift between surfaces.

        **Requires campaigns to be enabled**, like the rest of this section: when the
        Ideas `campaigns` setting is off the response is `403` with error code
        `campaigns_disabled` (distinct from the app-access `403 access_denied`).

        The campaign's own fields are shaped by the SAME serializer the campaigns
        LIST uses, so a card and its detail always agree: `status` is the DERIVED
        phase (`open` / `scheduled` / `closed` — never the stored open/closed enum),
        `icon` and `color` are whitelist-coerced, and the dates are ISO-8601.

        **The ideas list is paginated** (`page` / `per_page`, default 50 — the web's
        page size — clamped to 50). A campaign's thread is unbounded, so an
        unpaginated nested list would be a latent timeout. Crucially, `ideas_count`
        and `ideas_meta.total_count` are computed on the UNPAGINATED scope: they
        report the campaign's TRUE size, not the size of the page you asked for.

        **Sort** (`sort`, default `top`) mirrors the web's options:
        * `top` — most upvoted first (the default)
        * `new` — newest first
        * `discussed` — most comments (replies included) first
        * `score` — highest RICE first, unscored last. Degrades to `top` when RICE
          scoring is off for the workspace, exactly as the web does. An unknown value
          degrades to `top` too. The applied value is echoed as `ideas_meta.sort`.

        Every sort ends in an `id` DESC tiebreak, so paging never repeats or skips an
        idea — all four sort keys are non-unique (votes, a comment count, a nullable
        RICE score), and ties are common in a young campaign.

        **`reviewers`** resolves the panel the campaign routes to: its own group when
        it names one, else the workspace default — the same fallback the web detail
        and the list card apply. It carries three things:
        * `group.name` — the panel's name, for "Reviewed by <panel>". `group` is
          `null` (with `count: 0`) only when the campaign names no panel AND the
          workspace has no default.
        * `count` — the panel's **full** member count, whatever the preview length.
          Never read this off `members.size`; that would report 3 for a 40-person
          panel.
        * `members` — an avatar preview of at most **3** people, id-ordered. The same
          three-member preview the idea detail ships (`reviewers` /
          `reviewers_count`), so both screens render one avatar stack with a "+N"
          overflow computed from `count`. For the complete, paginated, searchable
          roster of THIS campaign's panel use
          `GET /ideas/campaigns/{id}/reviewers` — the campaign twin of
          `GET /ideas/{idea_id}/reviewers`, and the one to call here: a campaign
          screen has no `idea_id` in scope.

        `members` is **omitted entirely** while the `reviewer_names` setting is off,
        with `group` and `count` still present so a client can render
        "Reviewed by <panel> (N)" without naming anyone.

        Each idea row carries `rice_score` — and `rice_score_band`, the colour the
        web paints it — only while RICE scoring is on, matching the feed, search and
        idea-detail endpoints.
      parameters:
      - name: sort
        in: query
        required: false
        description: Ideas ordering. Unknown values (and `score` while scoring is
          off) fall back to `top`.
        schema:
          type: string
          enum:
          - top
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        description: 1-based page over the IDEAS list. An out-of-range page returns
          an empty list.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Ideas per page (default 50, clamped to 1..50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 50
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Campaign detail retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                properties:
                  campaign:
                    type: object
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    - creator
                    - reviewers
                    - can_edit
                    - can_delete
                    - ideas
                    - ideas_meta
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase — not the stored status enum.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; null when the campaign has no start
                          date.
                        example: '2026-07-21'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; null when the campaign is open-ended.
                        example: '2026-08-20'
                      icon:
                        type: string
                        description: Font Awesome glyph name with no `fa-` prefix
                        coerced to the authored set (default `bullhorn`).:
                        example: rocket
                      color:
                        type: string
                        description: Palette key
                        whitelist-coerced (default `blue`).:
                        example: forest
                      color_hex:
                        type: string
                        description: The palette key's primary hex
                        so a client can render without shipping the palette.:
                        example: "#2d6a4f"
                      has_my_idea:
                        type: boolean
                        description: Whether the CALLING user has authored at least
                          one idea in this campaign.
                        example: true
                      ideas_count:
                        type: integer
                        description: The campaign's TRUE idea count, from the unpaginated
                          scope — unaffected by `per_page`.
                        example: 14
                      creator:
                        type: object
                        description: The person who launched the campaign.
                        required:
                        - id
                        - name
                        - photo
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 1
                          name:
                            type: string
                            nullable: true
                            example: Anup K
                          photo:
                            type: string
                            description: Absolute avatar URL; never null (falls back
                              to a generated initials image).
                            example: https://…/avatar.jpg
                      reviewers:
                        type: object
                        required:
                        - group
                        - count
                        properties:
                          group:
                            type: object
                            nullable: true
                            description: The review panel this campaign routes to
                              (its own group, else the workspace default). Null only
                              when neither is configured.
                            required:
                            - id
                            - name
                            properties:
                              id:
                                type: integer
                                example: 44387
                              name:
                                type: string
                                example: Product Council
                          count:
                            type: integer
                            description: The panel's FULL member count (0 when there
                              is no panel) — independent of how many `members` are
                              previewed, so use it for "N reviewers" and for the avatar
                              stack's "+N" overflow.
                            example: 8
                          members:
                            type: array
                            maxItems: 3
                            description: An avatar preview of at most 3 panel members,
                              id-ordered — the same preview length the idea detail
                              ships. OMITTED entirely while the `reviewer_names` setting
                              is off. Use `GET /ideas/campaigns/{id}/reviewers` for
                              the paginated, searchable roster of this campaign's
                              panel.
                            items:
                              type: object
                              required:
                              - id
                              - name
                              - photo
                              properties:
                                id:
                                  type: integer
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                      can_edit:
                        type: boolean
                        description: Whether the CALLING user may manage this campaign
                          — its creator, or an Ideas admin (business admin-or-above,
                          or the Ideas app-admin role). Managing a campaign is ONE
                          right, as it is on the web, so this same predicate gates
                          `PATCH /ideas/campaigns/{id}` and `PATCH /ideas/campaigns/{id}/reopen`.
                          Render the Edit and Reopen affordances on it and they can
                          never 403 on tap.
                        example: true
                      can_delete:
                        type: boolean
                        description: Whether the CALLING user may delete this campaign
                          — the exact predicate `DELETE /ideas/campaigns/{id}` applies.
                          Always equal to `can_edit` (one manage right); shipped as
                          its own key because Delete is a separately-rendered affordance.
                        example: true
                      ideas:
                        type: array
                        description: One page of the campaign's ideas, in the requested
                          sort.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - description
                          - stage
                          - creator
                          - comments_count
                          - created_at
                          - vote_count
                          - has_voted
                          properties:
                            id:
                              type: integer
                              example: 87
                            title:
                              type: string
                              example: Dark mode for the mobile app
                            description:
                              type: string
                              nullable: true
                              description: The idea's plain-text body.
                              example: Ship a dark theme for the native app.
                            stage:
                              type: object
                              required:
                              - id
                              - name
                              - color
                              properties:
                                id:
                                  type: integer
                                  example: 2
                                name:
                                  type: string
                                  example: Reviewing
                                color:
                                  type: string
                                  example: "#6c757d"
                            creator:
                              type: object
                              required:
                              - id
                              - name
                              - photo
                              properties:
                                id:
                                  type: integer
                                  nullable: true
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                            comments_count:
                              type: integer
                              description: Comments + replies (non-deleted).
                              example: 3
                            created_at:
                              type: string
                              format: date-time
                              example: '2026-07-31T09:26:59Z'
                            vote_count:
                              type: integer
                              description: Positive-vote total.
                              example: 12
                            has_voted:
                              type: boolean
                              description: Whether the CALLING user upvoted this idea.
                              example: true
                            rice_score:
                              type: integer
                              nullable: true
                              description: Cached RICE score; null when not scored.
                                OMITTED entirely while RICE scoring is off for the
                                workspace.
                              example: 42
                            rice_score_band:
                              type: object
                              description: 'The colour band `rice_score` renders as,
                                so a client paints the score pill the same way the
                                web does without hardcoding our ranges. `tier` is
                                the stable key to switch on: `high` (>= 700), `medium`
                                (>= 300), `low` below that, `none` when the idea is
                                awaiting its first score. Travels with `rice_score`
                                and is OMITTED under the same gate (RICE scoring off
                                for the workspace).'
                              properties:
                                tier:
                                  type: string
                                  enum:
                                  - high
                                  - medium
                                  - low
                                  - none
                                  example: high
                                label:
                                  type: string
                                  description: Human label for the band.
                                  example: High
                                color:
                                  type: string
                                  description: Foreground hex the web uses.
                                  example: "#146c43"
                                background:
                                  type: string
                                  description: Background hex the web uses; `transparent`
                                    for the `none` band.
                                  example: "#d1f0e0"
                            voting_closes_on:
                              type: string
                              format: date
                              description: Present only when the voting-close-date
                                feature is on AND this idea has a date set.
                              example: '2026-08-15'
                      ideas_meta:
                        type: object
                        description: Pagination over the ideas list, plus the sort
                          actually applied.
                        required:
                        - total_count
                        - current_page
                        - per_page
                        - total_pages
                        - sort
                        properties:
                          total_count:
                            type: integer
                            description: The campaign's true idea count (unpaginated).
                            example: 14
                          current_page:
                            type: integer
                            example: 1
                          per_page:
                            type: integer
                            example: 50
                          total_pages:
                            type: integer
                            description: 0 when the campaign has no ideas.
                            example: 1
                          sort:
                            type: string
                            enum:
                            - top
                            - new
                            - discussed
                            - score
                            example: top
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            or campaigns are turned off for the workspace (error code `campaigns_disabled`).
        '404':
          description: No campaign with that id in the caller's business (error code
            `not_found`).
    patch:
      tags:
      - Ideas
      summary: Edit a campaign
      description: |
        Edits a campaign — the native mirror of the web
        `Apps::Ideas::CampaignsController#update` (**Manage ▸ Edit** on the campaign
        detail, which posts exactly this field set). Values are normalised and validated
        by the SAME shared code the create endpoint uses
        (`::Ideas::CampaignAttributes`), so a payload `POST /ideas/campaigns` accepts is
        accepted here too — and rejected the same way.

        **CREATOR OR IDEAS ADMIN.** The campaign's creator may edit it, and so may an
        Ideas admin (a business admin-or-above, or a user holding the Ideas app-admin
        role) — the web's `require_campaign_manager` rule verbatim. The admin half is
        deliberate: creator-only stranded campaigns whose creator had been deactivated
        or had left, with nobody able to fix the drive. Sitting on the campaign's
        **review panel grants nothing here** — reviewing ideas and managing the drive
        that collects them are different rights. Anyone else gets `403 forbidden`. The
        same predicate backs **`can_edit`** on the campaign detail, so a client can
        render the Edit affordance exactly when this endpoint would accept it.

        **Requires campaigns to be enabled**, like the rest of this section: with the
        Ideas `campaigns` setting off the response is `403 campaigns_disabled`.

        ### PATCH semantics — this is the part to read

        Every field is **optional**, and a key you do **not** send is left
        **unchanged**. The web always posts its whole form, so sending everything
        behaves identically — but an "edit title" screen must not be able to blank the
        brief or silently re-route the review panel just by omitting them. For the
        clearable fields, sending the key **empty** (`""` or `null`) is a distinct,
        meaningful instruction:

        | field | omitted | sent EMPTY | sent with a value |
        |---|---|---|---|
        | `title` | unchanged | `422` — can't be blank | replaces it (whitespace stripped, max 120) |
        | `brief` | unchanged | `422` — can't be blank | replaces it (stripped) |
        | `icon` | unchanged | resets to `bullhorn` | must be an authored glyph, else `422 invalid_icon` |
        | `color` | unchanged | resets to `blue` | must be a palette key, else `422 invalid_color` |
        | `start_date` | unchanged | **cleared** | strict ISO-8601 `YYYY-MM-DD`, else `422 invalid_date` |
        | `close_date` | unchanged | **cleared** → open-ended | strict ISO-8601, on or after the start date |
        | `reviewer_group_id` | unchanged | **cleared** → the workspace default panel | a group in THIS business, else `422 invalid_reviewer_group` |

        An empty **date** clears it because that is exactly what the web does — its
        emptied `date_field` posts `""`, which is how a manager makes a campaign
        open-ended again. An empty **brief** is refused even when the stored brief is
        already blank (a campaign predating the field), because the model only validates
        a brief it sees change and the card body would silently render empty.

        `status` is deliberately **not writable** here: closing and reopening are
        separate actions on both surfaces (`PATCH /ideas/campaigns/{id}/reopen`), so a
        PATCH fixing a typo can never reopen a finished drive. Sending `status` is
        ignored, not an error.

        **All-or-nothing:** the first unusable value is returned and **nothing** is
        written, so a rejected edit never lands half of itself.

        Responds with the SAME canonical campaign object
        `GET /ideas/campaigns/{id}` returns (one query object, one serializer — a client
        can drop it straight into the screen it saved from), plus `changed` and
        `changed_fields`. A payload matching what is already stored is a **200 no-op**
        with `changed: false` rather than an error, so a retried or double-tapped save is
        safe. `sort` / `page` / `per_page` apply to the nested `ideas` list exactly as
        they do on the detail GET.
      parameters:
      - name: sort
        in: query
        required: false
        description: Ordering of the nested ideas list, as on the detail GET.
        schema:
          type: string
          enum:
          - top
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        description: 1-based page over the nested IDEAS list.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Ideas per page in the response (default 50, clamped to 1..50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 50
      security:
      - BearerAuth: []
      requestBody:
        required: true
        description: Send ONLY the fields you are changing. An empty string clears
          a date or the reviewer group, and resets the icon/colour to the form default.
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                  maxLength: 120
                  description: Whitespace is stripped. Blank is a 422.
                  example: Q4 Cost Savings
                brief:
                  type: string
                  description: The campaign's description — the text its cards and
                    detail header render. Blank is a 422.
                  example: Ideas to cut operating cost this quarter.
                description:
                  type: string
                  description: Alias for `brief` — the key every campaign GET returns
                    it under, so a client can PATCH back the object it just read.
                    `brief` wins when both are sent.
                icon:
                  type: string
                  description: Font Awesome glyph name with NO `fa-` prefix, from
                    the authored set (`GET /ideas/config` → `campaign_options.icons`).
                    Empty resets to `bullhorn`.
                  example: lightbulb
                color:
                  type: string
                  description: Palette key (`GET /ideas/config` → `campaign_options.colors`).
                    Empty resets to `blue`.
                  example: teal
                start_date:
                  type: string
                  format: date
                  nullable: true
                  description: ISO-8601 `YYYY-MM-DD`. Send empty to clear it. Alias
                    `start_on`.
                  example: '2026-09-01'
                close_date:
                  type: string
                  format: date
                  nullable: true
                  description: ISO-8601 `YYYY-MM-DD`, on or after `start_date` (including
                    a start date already stored). Send empty to clear it — the campaign
                    becomes open-ended. Aliases `end_date`, `close_on`.
                  example: '2026-09-30'
                reviewer_group_id:
                  type: integer
                  nullable: true
                  description: The panel that reviews this campaign's ideas — a group
                    in THIS business (`GET /ideas/config` → `campaign_options.reviewer_groups`).
                    Send empty to store no panel, which resolves to the workspace
                    default at read time. OMIT it and the campaign's current panel
                    is untouched.
                  example: 44387
      responses:
        '200':
          description: Campaign updated (or a no-op when the payload matched what
            was stored — check `changed`).
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                - changed
                - changed_fields
                properties:
                  campaign:
                    type: object
                    description: The updated campaign — the SAME object (same serializer,
                      same keys, same nested `ideas` page) that `GET /ideas/campaigns/{id}`
                      returns; see that operation for the full field-by-field shape.
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    - creator
                    - reviewers
                    - can_edit
                    - can_delete
                    - ideas
                    - ideas_meta
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase, not the stored enum — a campaign
                          whose start date you just moved into the future reads `scheduled`.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: Null after you clear it.
                        example: '2026-09-01'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: Null when the campaign is open-ended.
                        example: '2026-09-30'
                      icon:
                        type: string
                        example: lightbulb
                      color:
                        type: string
                        example: teal
                      color_hex:
                        type: string
                        description: The palette key's primary hex.
                        example: "#0a97b0"
                      has_my_idea:
                        type: boolean
                        example: true
                      ideas_count:
                        type: integer
                        description: The campaign's TRUE idea count (unpaginated).
                        example: 14
                      creator:
                        type: object
                        description: "{ id, name, photo } — the person who launched
                          it."
                      reviewers:
                        type: object
                        description: "{ group, count, members? } — the panel it routes
                          to after the edit."
                      can_edit:
                        type: boolean
                        description: Whether the CALLER may edit/reopen it — the same
                          manage predicate this endpoint gates on.
                        example: true
                      can_delete:
                        type: boolean
                        description: Whether the CALLER may delete it. Always equal
                          to `can_edit` (one manage right).
                        example: true
                      ideas:
                        type: array
                        description: One page of the campaign's ideas.
                        items:
                          type: object
                      ideas_meta:
                        type: object
                        description: "{ total_count, current_page, per_page, total_pages,
                          sort }."
                  changed:
                    type: boolean
                    description: Whether anything actually moved. False on a no-op
                      save.
                    example: true
                  changed_fields:
                    type: array
                    description: The API field names that changed — `title`, `brief`,
                      `icon`, `color`, `start_date`, `close_date`, `reviewer_group_id`.
                      Empty on a no-op.
                    items:
                      type: string
                    example:
                    - title
                    - close_date
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), campaigns
            are turned off for the workspace (`campaigns_disabled`), or the caller
            is neither the campaign's creator nor an Ideas admin (`forbidden`).
        '404':
          description: No campaign with that id in the caller's business (error code
            `not_found`).
        '422':
          description: 'The edit was refused and NOTHING was written. `error.code`
            says which value: `invalid` (the model — blank title, over-length title,
            blank brief, close date before start date), `invalid_icon`, `invalid_color`,
            `invalid_date`, `invalid_reviewer_group`, or `update_failed` (infrastructure).
            Also `content_blocked` when the workspace''s content-moderation policy
            refuses the submitted text; the message is the policy''s own and is safe
            to show the author verbatim.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Ideas
      summary: Delete a campaign
      description: |
        Deletes a campaign — the native mirror of the web
        `Apps::Ideas::CampaignsController#destroy` (the **Delete** action on the
        campaign detail page). Both surfaces run the same
        `::Ideas::CampaignDeletionService`, so neither can leave state the other
        wouldn't.

        **CREATOR OR IDEAS ADMIN.** The campaign's creator may delete it, and so may
        an Ideas admin — a business admin-or-above, or a user holding the Ideas
        app-admin role. This is the web's `require_campaign_manager` rule verbatim,
        and it is deliberately **wider than the author-only idea delete**
        (`DELETE /ideas/{id}`): a creator-only rule stranded campaigns whose creator
        had been deactivated or had left the business, with nobody able to edit,
        close, reopen or delete them. Anyone else gets `403 forbidden` and the
        campaign is untouched. Note that contributing an idea to a campaign grants
        nothing here, and neither does the *"Who can create campaigns"* audience —
        that setting governs creating, not deleting.

        Check **`can_delete`** on `GET /ideas/campaigns/{id}` to decide whether to
        show the affordance: it is the same predicate this endpoint enforces.

        **The campaign's IDEAS SURVIVE.** A campaign is a grouping, not an owner: its
        ideas are detached (`campaign_id` set to `NULL`) and keep everything they had
        — their votes, comments, RICE scores, attachments, audit trail and lifecycle
        stage. They simply reappear in the feed with no campaign. Nothing else in the
        schema references a campaign, so there is no further cascade.
        `ideas_detached` reports how many moved, so a client can confirm the scope it
        warned the user about.

        The whole thing runs inside ONE transaction in a constant number of queries —
        the detach is a single set-based `UPDATE`, so deleting a campaign holding
        1,000 ideas costs what deleting an empty one does. A failure rolls everything
        back: the campaign survives AND its ideas are still attached.

        **Requires campaigns to be enabled**, like every action in this section: when
        the Ideas `campaigns` setting is off the response is `403` with error code
        `campaigns_disabled` (distinct from the app-access `403 access_denied`), and
        it is reported ahead of the narrower `forbidden` so a client hides the whole
        Campaigns tab rather than one button.

        **Not idempotent.** A second call returns `404 not_found`, because the
        campaign is genuinely gone. Clients should treat `404` on a retry as success.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Campaign deleted; its ideas were kept and detached.
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign_id
                - deleted
                - ideas_detached
                - message
                properties:
                  campaign_id:
                    type: integer
                    description: The id of the campaign that was deleted.
                    example: 12
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  ideas_detached:
                    type: integer
                    description: How many ideas left the campaign and are now uncategorised.
                      The ideas themselves are NOT deleted.
                    example: 14
                  message:
                    type: string
                    example: Campaign deleted. Its ideas were kept and detached.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            campaigns are turned off for the workspace (error code `campaigns_disabled`),
            or the caller is neither the campaign's creator nor an Ideas admin (error
            code `forbidden`). The campaign is left untouched.
        '404':
          description: No campaign with that id in the caller's business (error code
            `not_found`). A campaign belonging to another tenant reports `404`, never
            `403`, so the response can't confirm that the id exists elsewhere. A non-numeric
            id 404s at the router.
        '422':
          description: The delete could not be completed and was rolled back (error
            code `delete_failed`); the campaign survives and its ideas are still attached
            to it.
  "/ideas/campaigns/{id}/reopen":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign to reopen.
      schema:
        type: integer
    patch:
      tags:
      - Ideas
      summary: Reopen a closed campaign for submissions
      description: |
        Reopens a CLOSED campaign so members can post ideas to it again — the native
        mirror of the web "Reopen campaign" action (`Apps::Ideas::CampaignsController#reopen`,
        the Manage menu on the campaign detail, which offers Reopen exactly when the
        campaign's phase is `closed`). Both surfaces call the same
        `::Ideas::Campaign#reopen`, so neither can drift from the other.

        **One reopen writes two things:**

        1. the manual `closed` flag is cleared;
        2. a close date that has **already passed** is dropped. Without this second
           part a date-expired campaign would fall straight back to `closed` the
           moment it was reopened. A close date still in the FUTURE is left alone.

        Which happened is reported as `close_date_cleared`, because a client whose card
        is showing an end date needs to know the campaign no longer has one.

        **Authorization: the campaign's CREATOR, or an Ideas ADMIN** (a business
        admin-or-above, or a user holding the Ideas app-admin role) — the web's
        `require_campaign_manager` rule verbatim. Anyone else gets `403 forbidden`.
        The admin half is deliberate: creator-only stranded campaigns whose creator
        had been deactivated or had left, with nobody able to reopen them. Sitting on
        the campaign's **review panel grants nothing here** — reviewing ideas and
        managing the drive that collects them are different rights.

        Managing rights are reported on the campaign detail (`can_edit` on
        `GET /ideas/campaigns/{id}` is the same creator-or-admin predicate this gate
        applies), so a client can offer Reopen exactly when `status` is `closed` AND
        the caller holds that right, rather than tapping into a 403.

        **A campaign that isn't closed is a `200` no-op** (`changed: false`) that
        writes nothing — never an error — so a retried or double-tapped reopen is
        safe. Key off `changed`, not the status code. `message` says which no-op it
        was: "already open for submissions" for an open campaign, and when submissions
        actually open for a SCHEDULED one (whose submissions genuinely haven't opened
        yet).

        **A reopened campaign whose START date is still ahead comes back as
        `status: "scheduled"`, not `"open"`** — `status` is the derived phase
        (`Campaign#phase`), and its submissions legitimately open on that date. Read
        it back rather than assuming `"open"`.

        There is no request body: the campaign is identified by the path, and reopening
        takes no options. The response carries the campaign in the **same row shape the
        campaigns LIST returns**, so a client can drop it straight into the card or
        header it just acted on. Nothing about the campaign's ideas, review panel or
        roster changes when it reopens, so the heavier detail payload is not used —
        call `GET /ideas/campaigns/{id}` if the full detail is wanted.

        Gated on the `campaigns` setting like every campaign endpoint: a workspace with
        campaigns turned off answers `403 campaigns_disabled`, exactly as the web
        redirects the whole section away.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The campaign is open (or scheduled to open). `changed` distinguishes
            a reopen that just happened from a no-op on a campaign that wasn't closed.
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                - changed
                - close_date_cleared
                - message
                properties:
                  changed:
                    type: boolean
                    description: "`true` when this call reopened the campaign. `false`
                      when it was not closed and nothing was written."
                    example: true
                  close_date_cleared:
                    type: boolean
                    description: "`true` when the campaign's close date had already
                      passed and was therefore dropped (`end_date` is now `null`).
                      `false` for a future close date, which is kept, and for a no-op."
                    example: false
                  message:
                    type: string
                    description: Human-readable outcome — `Campaign reopened for submissions.`,
                      or on a no-op why nothing changed.
                    example: Campaign reopened for submissions.
                  campaign:
                    type: object
                    description: The campaign in the same row shape `GET /ideas/campaigns`
                      returns.
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase after the reopen — `scheduled`
                          when the start date is still ahead, otherwise `open`. Never
                          the stored status enum.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; null when the campaign has no start
                          date.
                        example: '2026-07-21'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; `null` when the campaign is open-ended
                          — including when this call just cleared an expired close
                          date (see `close_date_cleared`).
                        example: '2026-08-20'
                      icon:
                        type: string
                        description: Font Awesome glyph name with no `fa-` prefix
                        coerced to the authored set (default `bullhorn`).:
                        example: rocket
                      color:
                        type: string
                        description: Palette key
                        whitelist-coerced (default `blue`).:
                        example: forest
                      color_hex:
                        type: string
                        description: The palette key's primary hex
                        so a client can render without shipping the palette.:
                        example: "#2d6a4f"
                      has_my_idea:
                        type: boolean
                        description: Whether the CALLING user has authored at least
                          one idea in this campaign.
                        example: true
                      ideas_count:
                        type: integer
                        description: Ideas submitted to this campaign — unchanged
                          by a reopen.
                        example: 14
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), campaigns
            are turned off for the workspace (`campaigns_disabled`), or the caller
            is neither the campaign's creator nor an Ideas admin (`forbidden`). Review-panel
            members are NOT exempt.
        '404':
          description: No campaign with that id in the caller's business (`not_found`).
            A non-numeric id 404s at the router.
        '422':
          description: "`reopen_failed` — the campaign could not be saved (the message
            carries the model's own validation sentence). Nothing was written."
  "/ideas/campaigns/{id}/close":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign to close.
      schema:
        type: integer
    patch:
      tags:
      - Ideas
      summary: Close a campaign to new submissions
      description: |
        Closes a campaign so members can no longer post ideas to it — the native mirror
        of the web "Close campaign" action (`Apps::Ideas::CampaignsController#close`, the
        Manage menu on the campaign detail, which offers Close exactly when the
        campaign's phase is NOT `closed`). The twin of
        `PATCH /ideas/campaigns/{id}/reopen`; both surfaces call the same
        `::Ideas::Campaign#close`, so a close is identical wherever it comes from.

        **Its ideas survive, untouched.** Closing pauses NEW submissions only: every
        idea already collected stays open for voting, comments, RICE scoring and stage
        moves, and keeps appearing in the feed. This is **not** a delete and **not** an
        archive — use `DELETE /ideas/campaigns/{id}` if you mean to remove the campaign
        (which detaches its ideas rather than deleting them).

        **`end_date` is deliberately left alone.** A campaign closed EARLY keeps the
        close date it advertised; only the manual closed flag is written. The campaigns
        list reads the record's `updated_at` (which this call touches) as the end
        timestamp for exactly that case, so nothing needs the date rewritten — and
        rewriting it would silently change what every card says about the campaign.

        **Authorization: the campaign's CREATOR, or an Ideas ADMIN** (a business
        admin-or-above, or a user holding the Ideas app-admin role) — the web's
        `require_campaign_manager` rule verbatim. Anyone else gets `403 forbidden`.
        Sitting on the campaign's **review panel grants nothing** — reviewing ideas and
        managing the drive that collects them are different rights.

        **A SCHEDULED campaign can be closed** (the web offers Close for it too) — that
        is cancelling a drive before it ever opens. Its `start_date` is not rewritten,
        and it comes back `status: "closed"`.

        **An already-closed campaign is a `200` no-op** (`changed: false`) that writes
        nothing, so a retried or double-tapped close is safe. Key off `changed`, not the
        status code. `message` distinguishes the two ways a campaign is already closed,
        because they are different facts: closed by hand ("This campaign is already
        closed.") versus closed by its close date passing ("This campaign already closed
        on <date>."). In the second case the stored status stays `open` — writing the
        flag would change nothing a client can see.

        There is no request body: the campaign is identified by the path, and closing
        takes no options. The response carries the campaign in the **same row shape the
        campaigns LIST returns**, review panel included, so a client can drop it straight
        into the card or header it just acted on.

        Gated on the `campaigns` setting like every campaign endpoint: a workspace with
        campaigns turned off answers `403 campaigns_disabled`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The campaign is closed. `changed` distinguishes a close that
            just happened from a no-op on a campaign that was already closed.
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                - changed
                - message
                properties:
                  changed:
                    type: boolean
                    description: "`true` when this call closed the campaign. `false`
                      when it was already closed and nothing was written."
                    example: true
                  message:
                    type: string
                    description: Human-readable outcome — `Campaign closed.`, or on
                      a no-op which kind of already-closed it was.
                    example: Campaign closed.
                  campaign:
                    type: object
                    description: The campaign in the same row shape `GET /ideas/campaigns`
                      returns.
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    - reviewers
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase after the close — `closed`,
                          including for a campaign that was still `scheduled` beforehand.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: closed
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; never rewritten by a close.
                        example: '2026-07-21'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601, and **unchanged by this call** — a
                          campaign closed early keeps the close date it advertised.
                          `null` only when the campaign was already open-ended.
                        example: '2026-08-20'
                      icon:
                        type: string
                        description: Font Awesome glyph name with no `fa-` prefix
                        coerced to the authored set (default `bullhorn`).:
                        example: rocket
                      color:
                        type: string
                        description: Palette key
                        whitelist-coerced (default `blue`).:
                        example: forest
                      color_hex:
                        type: string
                        description: The palette key's primary hex
                        so a client can render without shipping the palette.:
                        example: "#2d6a4f"
                      has_my_idea:
                        type: boolean
                        description: Whether the CALLING user has authored at least
                          one idea in this campaign.
                        example: true
                      ideas_count:
                        type: integer
                        description: Ideas submitted to this campaign — unchanged
                          by a close; they are not deleted or detached.
                        example: 14
                      reviewers:
                        type: object
                        description: The campaign's review panel, in the same `{ group,
                          count, members? }` shape the list and the detail report.
                          `members` is capped at 3 and omitted entirely while the
                          `reviewer_names` setting is off.
                        required:
                        - group
                        - count
                        properties:
                          group:
                            type: object
                            nullable: true
                            required:
                            - id
                            - name
                            properties:
                              id:
                                type: integer
                                example: 44387
                              name:
                                type: string
                                example: Product Council
                          count:
                            type: integer
                            description: The panel's FULL member count
                            not `members.size`.:
                            example: 12
                          members:
                            type: array
                            maxItems: 3
                            items:
                              type: object
                              required:
                              - id
                              - name
                              - photo
                              properties:
                                id:
                                  type: integer
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), campaigns
            are turned off for the workspace (`campaigns_disabled`), or the caller
            is neither the campaign's creator nor an Ideas admin (`forbidden`). Review-panel
            members are NOT exempt.
        '404':
          description: No campaign with that id in the caller's business (`not_found`).
            A non-numeric id 404s at the router.
        '422':
          description: "`close_failed` — the campaign could not be saved (the message
            carries the model's own validation sentence). Nothing was written."
  "/ideas/campaigns/{id}/reviewers":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: A campaign's reviewer roster
      description: |
        The people who review this campaign's ideas — the members of the panel the
        campaign routes to: **its own panel when it names one, else the workspace
        default**. That is the identical resolution the web campaign detail
        (`Apps::Ideas::CampaignsController#show`), the campaigns index card and
        `Ideas::CampaignDetailQuery` apply, so this roster can never disagree with
        them about whose panel it is.

        This is the **full-list twin** of the capped `reviewers.members` avatar
        preview on `GET /ideas/campaigns/{id}` — exactly the relationship
        `GET /ideas/{idea_id}/reviewers` has to the idea detail. Render the stack
        from the detail, open this for the complete, filterable list. The two agree
        by construction: same panel, same order, and the detail's `reviewers.count`
        equals this endpoint's `meta.total_count`.

        **Ordering** is `users.id` ascending — the order the web campaign detail
        renders its roster in (its `where(id: member_ids)` carries no `ORDER BY`),
        and deterministic, so paging never skips or repeats a reviewer.

        **Search** (`q`) matches the term case-insensitively against each reviewer's
        name (name / first / last) and email — the server-side equivalent of the
        web panel's client-side filter. `%` and `_` are matched literally, and
        `meta.total_count` narrows to the matches while the `panel` node does not.

        **Access** is every member who can open the campaign: seeing who reviews it
        is not a manage right, matching the web detail, which shows the panel to
        every reader. Three gates apply, in this order:
        * the Ideas app must be accessible (`403 access_denied`);
        * campaigns must be enabled (`403 campaigns_disabled`) — the whole section,
          like every campaign endpoint;
        * the `reviewer_names` setting must be ON (`403 reviewer_names_hidden`), the
          same gate the idea roster carries. It is checked BEFORE the campaign
          lookup, so a workspace that hides names answers the same 403 for a
          campaign that does not exist — the gate cannot be used to probe for ids.

        `panel` names the group the roster belongs to (title the screen "Reviewed by
        <panel>"), or `null` when the campaign names no panel AND the workspace has
        no default — then `reviewers` is empty and `meta.total_count` is 0. A
        `reviewer_group_id` pointing at another tenant's group resolves to `null`
        the same way: panel lookup is business-scoped, so it can never roster
        somebody else's people.

        **Query budget: constant** — one cached membership lookup, one COUNT, and one
        page of users with the whole avatar-variant chain preloaded. Neither the
        panel's size nor `per_page` adds a query.
      parameters:
      - name: q
        in: query
        required: false
        description: Free-text search over reviewer name / email.
        schema:
          type: string
        example: alice
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1). A page past the end is an empty
          200.
        schema:
          type: integer
          minimum: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, clamped to 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
        example: 20
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Campaign reviewer roster retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign_id
                - reviewers
                - panel
                - meta
                properties:
                  campaign_id:
                    type: integer
                    description: The campaign the roster belongs to, echoed so a client
                      can match the response to the card it opened.
                    example: 12
                  reviewers:
                    type: array
                    description: The panel members, `users.id`-ordered, one page at
                      a time.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - photo
                      properties:
                        id:
                          type: integer
                          description: The reviewer's user id.
                          example: 334
                        name:
                          type: string
                          description: Display name (full_name
                          falling back to name).:
                          example: Alice Anderson
                        photo:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (request host prepended
                            to a relative path; ui-avatars fallback otherwise).
                          example: https://ui-avatars.com/api/?name=Alice%20Anderson&size=40&background=random
                  panel:
                    type: object
                    nullable: true
                    description: The review panel (NotificationRecipientGroup) the
                      roster belongs to — the campaign's own group, else the workspace
                      default. Null when neither is configured (or the stored group
                      belongs to another tenant), in which case `reviewers` is empty.
                    required:
                    - id
                    - name
                    properties:
                      id:
                        type: integer
                        example: 44387
                      name:
                        type: string
                        example: Campaign Crew
                  meta:
                    type: object
                    required:
                    - total_count
                    - current_page
                    - per_page
                    - total_pages
                    properties:
                      total_count:
                        type: integer
                        description: Reviewers matching the active search — the panel's
                          FULL size when `q` is blank, computed on the unpaginated
                          relation, so it is unaffected by `per_page`. Equals the
                          campaign detail's `reviewers.count`.
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        description: 0 when the roster is empty.
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (`access_denied`),
            campaigns are turned off for the workspace (`campaigns_disabled`), or
            reviewer names are hidden (`reviewer_names_hidden` — reported even for
            an unknown campaign id, since the gate precedes the lookup).
        '404':
          description: No campaign with that id in the caller's business (`not_found`).
            A campaign belonging to another tenant reports `404`, never `403`; a non-numeric
            id 404s at the router.
  "/ideas/{idea_id}/reviewers":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: An idea's reviewer roster
      description: |
        The people who can review this idea — the members of the review panel it
        routes to (its campaign's panel when the campaign sets one, else the
        workspace default panel). The native mirror of the web idea detail's
        "Review Panel" modal (Apps::Ideas::IdeasController#show), backed by the
        shared `::Ideas::ReviewerRosterQuery` + the same panel resolution the web
        uses, so the roster can't drift between surfaces.

        **Ordering** is `users.id` ascending — the same stable order the web modal
        renders, and deterministic so pagination never skips or repeats a
        reviewer.

        **Search** (`q`) matches the term (case-insensitively) against each
        reviewer's name and email — the server-side equivalent of the web modal's
        "Search reviewers" box.

        **Access** requires the `reviewer_names` setting to be ON. When an admin
        has turned reviewer names off, the web shows only the panel's group name;
        this endpoint returns `403 reviewer_names_hidden` so the client can hide
        the roster affordance rather than tap into an error.

        `panel` names the group the roster belongs to (the "Reviewed by …" line),
        or `null` when the idea has no panel configured (then `reviewers` is empty
        and `meta.total_count` is 0). `meta.total_count` also drives the
        "N reviewers" header.
      parameters:
      - name: q
        in: query
        required: false
        description: Free-text search over reviewer name / email.
        schema:
          type: string
        example: alice
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, clamped to 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
        example: 20
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Reviewer roster retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - reviewers
                - panel
                - meta
                properties:
                  reviewers:
                    type: array
                    description: The panel members, id-ordered, one page at a time.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - photo
                      properties:
                        id:
                          type: integer
                          description: The reviewer's user id.
                          example: 334
                        name:
                          type: string
                          description: Display name (full_name
                          falling back to name).:
                          example: Alice Anderson
                        photo:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (request host prepended
                            to a relative path; ui-avatars fallback otherwise).
                          example: https://ui-avatars.com/api/?name=Alice%20Anderson&size=40&background=random
                  panel:
                    type: object
                    nullable: true
                    description: The review panel (NotificationRecipientGroup) the
                      roster belongs to; null when the idea has no panel configured.
                    required:
                    - id
                    - name
                    properties:
                      id:
                        type: integer
                        example: 44387
                      name:
                        type: string
                        example: Idea Reviewers
                  meta:
                    type: object
                    required:
                    - total_count
                    - current_page
                    - per_page
                    - total_pages
                    properties:
                      total_count:
                        type: integer
                        description: Total reviewers matching the active search (the
                          whole panel when q is blank).
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Ideas app is not accessible (`access_denied`), or
            reviewer names are turned off for the workspace (`reviewer_names_hidden`).
        '404':
          description: No idea with that id in the caller's business (`not_found`).
  "/ideas/{idea_id}/voters":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: The users who upvoted an idea
      description: |
        The people who upvoted this idea. The native mirror of the web idea
        detail's "See who upvoted" modal
        (`Apps::Ideas::IdeasController#voters`), backed by the shared
        `::Ideas::VoterListQuery`, so the two surfaces can't drift in ordering, in
        what a search term matches, or in who is listed at all.

        **POSITIVE votes only.** A row is an `up` vote, plus any legacy `yes`
        from the retired thumbs model (which still counts as support). A legacy
        `no` is never listed: there is no down-vote data model and no
        down-voter list.

        **Ordering** is newest vote first (`voted_on` descending), with a
        **`id` descending tiebreak**. The tiebreak is load-bearing, not
        decoration — votes seeded in bulk share one timestamp, and a tied set has
        no inherent order, so without it a page boundary could repeat or skip a
        voter the user already scrolled past. Paging is therefore stable across
        repeated requests.

        **Search** (`q`) matches the term as a case-insensitive substring of the
        voter's name — the server-side equivalent of the web modal's
        "Search by name…" box. It matches mid-word, and `%` / `_` are matched
        literally rather than as SQL wildcards. Blank/absent is a no-op. The
        applied term is echoed back as `query` (trimmed, `""` when not
        searching). A term that matches nothing is a normal `200` with an empty
        `voters` array — never an error.

        **Two different totals, deliberately.** `meta.total_count` is the
        SEARCHED total and is what pagination is over. `total_votes` is the
        idea's UNFILTERED positive-vote total, so a client can keep showing the
        real "N votes" headline while the user filters the list — it matches the
        vote count on the idea card and detail.

        `id` on each row is the **USER's** id, not the vote row's: it is what a
        client needs to open that person's profile, which is what tapping a voter
        does in the mobile design. The vote has no client-facing identity here.

        `photo` is **never null** — the platform falls back to a generated
        initials avatar when the user has no profile photo, so a client needs no
        placeholder branch. `is_you` flags the calling user's own row, driving the
        "You" pill next to their name.
      parameters:
      - name: q
        in: query
        required: false
        description: Case-insensitive substring match on the voter's name. Matches
          mid-word; `%` and `_` are literal. Blank/absent disables the search. Narrows
          `meta.total_count` but NOT `total_votes`.
        schema:
          type: string
        example: kejriwal
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1). An out-of-range page returns
          an empty list.
        schema:
          type: integer
          minimum: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, clamped to 1..50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Voters retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - voters
                - query
                - idea_id
                - total_votes
                - meta
                properties:
                  voters:
                    type: array
                    description: One row per positive vote, newest first.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - photo
                      - voted_on
                      - is_you
                      properties:
                        id:
                          type: integer
                          description: The USER's id (not the vote row's) — use it
                            to open the person's profile.
                          example: 455
                        name:
                          type: string
                          nullable: true
                          description: The voter's full name.
                          example: Amaya Kejriwal
                        photo:
                          type: string
                          description: Absolute avatar URL. Never null — falls back
                            to a generated initials image when the user has no profile
                            photo.
                          example: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/…/avatar.jpg
                        voted_on:
                          type: string
                          format: date-time
                          nullable: true
                          description: ISO-8601 timestamp of when this user upvoted.
                          example: '2026-07-20T10:00:00Z'
                        is_you:
                          type: boolean
                          description: True on the CALLING user's own row — drives
                            the "You" pill. At most one row per response.
                          example: false
                  query:
                    type: string
                    description: The search term actually applied, trimmed. Empty
                      string when not searching.
                    example: kejriwal
                  idea_id:
                    type: integer
                    description: Echo of the idea these voters belong to.
                    example: 4
                  total_votes:
                    type: integer
                    description: The idea's UNFILTERED positive-vote total — unchanged
                      by `q`, so the "N votes" headline stays truthful while filtering.
                      Matches the vote count on the idea card.
                    example: 9
                  meta:
                    type: object
                    description: Pagination over the SEARCHED set.
                    properties:
                      total_count:
                        type: integer
                        description: Voters matching the search.
                        example: 9
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        description: 0 when nothing matches.
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
  "/ideas/{idea_id}/stage":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea to move.
      schema:
        type: integer
    patch:
      tags:
      - Ideas
      summary: Move an idea to a lifecycle stage
      description: |
        Moves an idea to another lifecycle stage — the native mirror of the web
        reviewer action (`Apps::Ideas::IdeasController#stage`: the stage picker on
        the idea detail, and the Kanban board's drag-to-move). Both surfaces, plus
        the Ideas agent's `move_stage` tool, run the SAME
        `::Ideas::StageTransitionService`, so a move is identical wherever it comes
        from.

        **One move writes four things, in one transaction** — if any fails, none
        of it sticks (an idea that moved with no audit trail is exactly what the
        transaction prevents):

        1. the idea's stage;
        2. an **audit entry** recording who moved it, from where, to where, and the
           note — the reviewer accountability trail shown on the idea detail;
        3. the `note`, if given, as a **real comment on the idea's Discussion**, so
           every member — not just reviewers — can see why it moved;
        4. `declined_from_stage_id`, **only** when the target stage's category is
           `declined` — the off-ramp the detail page's Lifecycle card reads to say
           which stage the idea fell out of. It is left untouched by any other move.

        A stage-change **notification** is then sent to the idea's author (outside
        the transaction, and subject to the workspace's `notify_stage_change`
        setting), so a delivery failure can never undo a committed move.

        **Authorization: review-panel membership on THIS idea, with NO admin
        bypass** — the web rule verbatim. The panel is the idea's campaign's
        reviewer group when its campaign sets one, else the workspace default
        panel. An Ideas admin who is not on that panel gets `403`, and so does the
        idea's own author. `GET /ideas/config` reports the panel
        (`idea_reviewers`), and `GET /ideas/{id}` reports the idea's own, so a
        client can hide the stage picker rather than tapping into a 403.

        **Already in the target stage** is a `200` with `changed: false` and
        **nothing written** — no audit entry, no duplicate note comment, no
        notification. The call is therefore idempotent and safe to retry. (The web
        action answers its Kanban caller with a bare `204` here; this endpoint
        returns a body because a native client needs the idea's current state
        either way, and every other endpoint on this surface returns one.)

        There is **no stage list here** — `GET /ideas/config` already returns the
        workspace's ordered pipeline in the same stage shape, which is what
        populates a stage picker.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - stage_id
              properties:
                stage_id:
                  type: integer
                  description: The target stage. Must be a lifecycle stage of the
                    caller's own business — an unknown id, a non-numeric one, or another
                    tenant's stage is `422 unknown_stage` and moves nothing.
                  example: 412
                note:
                  type: string
                  description: Optional rationale. Recorded on the audit entry AND
                    posted to the idea's Discussion as a comment by the caller. Blank/whitespace
                    is treated as absent (nothing is posted).
                  example: Moving to Planned — costed and scheduled for Q4.
          multipart/form-data:
            schema:
              type: object
              required:
              - stage_id
              properties:
                stage_id:
                  type: integer
                  example: 412
                note:
                  type: string
                  example: Moving to Planned.
      responses:
        '200':
          description: The idea is in the target stage. `changed` distinguishes a
            move that just happened from a no-op (it was already there).
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - changed
                - stage
                - previous_stage
                - note_posted
                - message
                properties:
                  idea_id:
                    type: integer
                    example: 42
                  changed:
                    type: boolean
                    description: "`true` when this call moved the idea (audit entry
                      written, note posted if given, author notified). `false` when
                      it was already in that stage and nothing was written."
                    example: true
                  stage:
                    allOf:
                    - "$ref": "#/components/schemas/IdeaLifecycleStage"
                    description: The stage the idea is in now.
                  previous_stage:
                    allOf:
                    - "$ref": "#/components/schemas/IdeaLifecycleStage"
                    description: The stage it came from. `null` if the idea's previous
                      stage row no longer exists. On a no-op this is the same stage
                      as `stage`.
                  declined_from_stage_id:
                    type: integer
                    nullable: true
                    description: The idea's current off-ramp pointer — the stage it
                      was declined FROM, which the detail page's Lifecycle card reads.
                      A move into a `declined` stage sets it; nothing clears it if
                      the idea is later revived, so a previously-deferred idea still
                      reports it after moving back out. `null` for an idea never declined.
                    example:
                  note_posted:
                    type: boolean
                    description: Whether a note was actually added to the Discussion.
                    example: true
                  message:
                    type: string
                    description: Human-readable outcome, e.g. `Moved to “Planned”.`
                      or `Already in “Planned”.`
                    example: Moved to “Planned”.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Ideas app is not accessible (`access_denied`), or
            the caller is not a member of this idea's review panel (`forbidden`).
            Admins are NOT exempt — panel membership is the only grant.
        '404':
          description: No idea with that id in the caller's business (`not_found`).
        '422':
          description: "`stage_id_required` when it is missing; `unknown_stage` when
            it is not a lifecycle stage of this business; `stage_change_failed` when
            the transaction could not be committed (nothing moved)."
  "/ideas/{idea_id}/vote":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    post:
      tags:
      - Ideas
      summary: Cast the caller's upvote on an idea
      description: |
        Records the caller's single upvote (Ideas is upvote-only — one vote per
        user per idea). The native mirror of the web
        Apps::Ideas::VotesController#create.

        * **Idempotent** — voting again is a no-op that still returns `200` with
          the current state (the DB is unique on user + idea).
        * Refused with `422 voting_closed` once the idea's voting-close date has
          passed — only on a workspace whose `close_date_enabled` is true. Where
          that feature is off the date is inert and the vote is accepted.

        Returns the idea's resulting vote state. `vote_count` / `has_voted` use
        the SAME key names the feed card does, so a client can patch its cached
        card in place; `vote_change_allowed` tells the client whether to show a
        remove-vote affordance.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Vote recorded (or already present).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/IdeaVoteState"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
        '422':
          description: Voting has closed for this idea (error code `voting_closed`).
    delete:
      tags:
      - Ideas
      summary: Remove the caller's upvote from an idea
      description: |
        Removes the caller's upvote. The native mirror of the web
        Apps::Ideas::VotesController#destroy.

        **Gated by the "Allow removing an upvote" workspace setting
        (`vote_change`).** When that setting is OFF the upvote is final: this
        endpoint returns `403 vote_change_disabled` and the vote is kept. The
        gate is checked BEFORE any delete, so it applies even when the caller has
        no vote to remove. When the setting is ON, removal is **idempotent** —
        removing with no vote present is a no-op `200`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Vote removed (or none was present).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/IdeaVoteState"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR removing an upvote is disabled for the workspace (error code `vote_change_disabled`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
  "/ideas/{idea_id}/score":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea being scored.
      schema:
        type: integer
        example: 91
    patch:
      tags:
      - Ideas
      summary: Add or update an idea's RICE score (reviewer)
      description: |
        Writes the four RICE inputs — **Reach, Impact, Confidence, Effort** — onto an
        idea, the native mirror of the web reviewer's Stage-Management score form
        (`Apps::Ideas::IdeasController#score`). Both surfaces call the same
        `::Ideas::ScoreUpdateService`, so the coercion, the effort floor and the
        audit trail cannot drift.

        **Gates — identical to the web, in this order:**
        1. The Ideas app must be accessible to the caller → `403 access_denied`.
        2. RICE scoring must be enabled for the workspace → `403 scoring_disabled`.
        3. The caller must be on **this idea's review panel** (its campaign's group
           when it has one, else the workspace default) → `403 not_a_reviewer`.
           **Panel membership is the only grant** — an Ideas admin who is not on the
           panel is refused, exactly as on the web.
        4. The idea must exist in the caller's business → `404 not_found` (a foreign
           id is indistinguishable from one that never existed).

        **Field rules**
        * `reach` is stored as an integer; `impact`, `confidence` and `effort` as floats.
        * `effort` is floored at **0.25** so RICE never divides by zero — but only
          when a value is actually supplied.
        * Sending a field **empty** CLEARS it (stores null); **omitting** a field
          leaves the stored value untouched (partial update).
        * `confidence` is optional — when null, RICE treats it as `1.0`.

        **RICE only computes when Reach, Impact and Effort are all present.** When one
        is blank the values still save but the idea stays unscored: the response
        returns `scored: false` and `rice_score: null`, and `message` says so rather
        than implying a score landed.

        `rice_score = round(reach × impact × confidence ÷ max(effort, 0.25))`

        **Idempotent** — re-sending the same values is a no-op save, and the audit
        logger coalesces a reviewer's rapid successive edits into a single entry
        (dropping it entirely if they revert to where the session started).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - idea
              properties:
                idea:
                  type: object
                  description: 'The RICE inputs. All four are optional: omit a field
                    to leave it unchanged, send it empty to clear it.'
                  properties:
                    reach:
                      type: integer
                      nullable: true
                      description: How many people this affects (stored as an integer).
                      example: 100
                    impact:
                      type: number
                      nullable: true
                      description: Impact multiplier per person.
                      example: 2
                    confidence:
                      type: number
                      nullable: true
                      description: Confidence factor (0–1). Optional — null is treated
                        as 1.0.
                      example: 0.5
                    effort:
                      type: number
                      nullable: true
                      description: Person-months of effort. Floored at 0.25 when supplied.
                      example: 2
      responses:
        '200':
          description: Score saved (see `scored` for whether RICE could be computed)
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - rice_score
                - rice_score_band
                - scored
                - score
                - message
                properties:
                  idea_id:
                    type: integer
                    example: 91
                  rice_score:
                    type: integer
                    nullable: true
                    description: The computed RICE score, or null when a required
                      input (reach / impact / effort) is blank.
                    example: 50
                  rice_score_band:
                    type: object
                    description: 'The colour band `rice_score` renders as, so a client
                      paints the score pill the same way the web does without hardcoding
                      our ranges. `tier` is the stable key to switch on: `high` (>=
                      700), `medium` (>= 300), `low` below that, `none` when the values
                      saved but RICE could not be computed. Always present here —
                      this endpoint 403s outright when RICE scoring is off — so a
                      client can recolour the pill straight off this response instead
                      of refetching the row.'
                    properties:
                      tier:
                        type: string
                        enum:
                        - high
                        - medium
                        - low
                        - none
                        example: high
                      label:
                        type: string
                        description: Human label for the band.
                        example: High
                      color:
                        type: string
                        description: Foreground hex the web uses.
                        example: "#146c43"
                      background:
                        type: string
                        description: Background hex the web uses; `transparent` for
                          the `none` band.
                        example: "#d1f0e0"
                  scored:
                    type: boolean
                    description: False when the values saved but the idea remains
                      unscored — it is then invisible to the score sort and the Value/Effort
                      matrix.
                    example: true
                  score:
                    type: object
                    description: The stored inputs after coercion (nulls for cleared
                      fields).
                    required:
                    - reach
                    - impact
                    - confidence
                    - effort
                    properties:
                      reach:
                        type: integer
                        nullable: true
                        example: 100
                      impact:
                        type: number
                        nullable: true
                        example: 2
                      confidence:
                        type: number
                        nullable: true
                        example: 0.5
                      effort:
                        type: number
                        nullable: true
                        example: 2
                  message:
                    type: string
                    description: '"Score updated." when RICE computed, otherwise an
                      explanation that Reach, Impact and Effort are all required.'
                    example: Score updated.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: The `idea` object was missing from the body (`parameter_missing`).
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), RICE scoring
            is turned off for the workspace (`scoring_disabled`), or the caller is
            not on this idea's review panel (`not_a_reviewer` — no admin bypass).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
        '422':
          description: The score could not be saved (`score_failed`).
  "/ideas/{idea_id}/comments":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: An idea's discussion thread
      description: |
        A paginated list of the idea's **top-level** comments, each inlining its
        direct replies. Threading is ONE level deep, matching the web idea detail
        (`Apps::Ideas::IdeasController#show`) — a reply always has `replies: []`.
        Modelled on the Wikis comment API.

        * **Ordering** — oldest first, like the web thread, with an `id` tiebreak
          so comments sharing a `created_at` never repeat or get skipped across
          page boundaries.
        * **`meta.total_count` counts TOP-LEVEL comments** — that is what
          pagination is over. **`total_comments`** is the full discussion volume
          (comments *plus* replies), which is the number the feed card's
          `comments_count` shows, so a client's header keeps agreeing with the
          card it came from.
        * **Inlined `replies` are capped at 20 per comment.** `per_page` bounds the
          TOP-LEVEL list only, so without a cap one response could carry 50 threads
          × every reply they have (~1.7 MB). `replies_count` is the TRUE total from
          a COUNT — it can exceed `replies.length` — and `replies_truncated` says
          the array is partial. Fetch the rest in *replies mode* (next bullet); its
          default `per_page` is the same 20, so `page=2` continues exactly where
          the inlined array stopped, with no overlap and no gap.
        * **`parent_comment_id`** switches to *replies mode*: pass a top-level
          comment's id to page through that comment's replies instead of the
          thread. An id that is unknown, names a reply, or belongs to a different
          idea returns an empty page (not an error) — the parent is resolved
          through this idea's own comments, so a forged id can never widen the
          result.
        * Soft-deleted comments never appear, and are excluded from both counts.
        * Page size defaults to 20 (max 50). The web renders a fixed 25 per page;
          this surface exposes client-controllable paging like every other Ideas
          endpoint, while using the identical ordering.

        Reading needs no permission beyond access to the Ideas app; a foreign or
        missing idea id returns `404`.
      security:
      - BearerAuth: []
      parameters:
      - name: parent_comment_id
        in: query
        required: false
        description: When present, return the REPLIES of this top-level comment instead
          of the top-level thread.
        schema:
          type: integer
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, maximum 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Thread retrieved successfully.
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - comments
                - total_comments
                - meta
                properties:
                  idea_id:
                    type: integer
                    description: The idea id (echoes the path id).
                    example: 42
                  comments:
                    type: array
                    description: Top-level comments (or replies, in replies mode),
                      oldest first.
                    items:
                      "$ref": "#/components/schemas/IdeaComment"
                  total_comments:
                    type: integer
                    description: The idea's full discussion volume — comments plus
                      replies, excluding soft-deleted rows. Matches the feed card's
                      `comments_count`, and is never below `meta.total_count`.
                    example: 9
                  meta:
                    type: object
                    required:
                    - total_count
                    - current_page
                    - per_page
                    - total_pages
                    properties:
                      total_count:
                        type: integer
                        description: TOP-LEVEL comments (what pagination is over),
                          or replies in replies mode.
                        example: 4
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        description: 0 when the thread is empty.
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
    post:
      tags:
      - Ideas
      summary: Post a comment or reply on an idea
      description: |
        Posts a comment on an idea, or a **reply** when `parent_comment_id` is
        given. The native mirror of the web
        `Apps::Ideas::CommentsController#create`, sharing
        `Platform::Commentable#post_comment` so both surfaces store a comment —
        and resolve its mentions — identically.

        **Mentions** — send the platform-standard `@[Name](mention:id)` tokens
        inline in `body` (the same markup the web and feeds composers emit). The
        server resolves them to `mentioned_user_ids` and notifies each mentioned
        user. The raw tokens are preserved in the returned `body` so a client can
        linkify them.

        **No attachments** — an idea comment is text + mentions only, matching the
        web composer (which posts a bare `comment[body]` with no file field). An
        idea comment is a plain `Platform::Comment`, which declares no attachment
        association, so a stray `attachments` param is ignored rather than stored.

        **Threading is ONE level.** `parent_comment_id` must name a TOP-LEVEL
        comment of THIS idea; replying to a reply returns `422`
        `reply_depth_exceeded`, and a parent from another idea's thread returns
        `422` `parent_not_found`.

        **Notifications** replicate the web fan-out: the idea's author is told
        about the activity, a reply ALSO notifies the author of the comment being
        replied to, and @mentions ping everyone tagged EXCEPT those recipients and
        the commenter — so one post never sends the same person both a reply and a
        mention. Delivery is best-effort and never fails an accepted comment.

        **At most 25 @mentioned people are notified per comment**, and delivery is
        queued rather than done inside this request (so a 201 means "accepted", not
        "delivered"). The cap is on the NOTIFICATION only: the comment always saves
        with its full `mentioned_user_ids`, every mention still renders and still
        counts for the recipient's Mentions filter. `mentioned_user_ids` is returned
        in full, so a client can compare its length with 25 to know the cap bit. A
        comment naming more than two dozen people individually is a broadcast — use
        Broadcast or a campaign for that audience.

        Returns the created comment in the same shape the thread listing uses,
        plus `total_comments` (comments + replies) so a client can patch the count
        it is already showing without re-fetching.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  description: Comment text; may contain `@[Name](mention:id)` mention
                    tokens. Blank/whitespace-only is rejected.
                  example: Great idea @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
                  description: Top-level comment id to reply to. Omit (or send blank)
                    for a new top-level comment.
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  example: Great idea @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                - total_comments
                properties:
                  comment:
                    "$ref": "#/components/schemas/IdeaComment"
                  total_comments:
                    type: integer
                    description: The idea's discussion volume after the post (comments
                      + replies) — the same number the feed card's `comments_count`
                      shows.
                    example: 7
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id exists in the caller's business (error
            code `not_found`).
        '422':
          description: 'The body was blank (`body_required`), or `parent_comment_id`
            was invalid: `parent_not_found` (not a comment on this idea) or `reply_depth_exceeded`
            (it is itself a reply). Also `content_blocked` when the workspace''s content-moderation
            policy refuses the submitted text; the message is the policy''s own and
            is safe to show the author verbatim.'
  "/ideas/{idea_id}/comments/{id}":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea the comment belongs to.
      schema:
        type: integer
        example: 91
    - name: id
      in: path
      required: true
      description: The comment (or reply) to edit or delete.
      schema:
        type: integer
        example: 5512
    patch:
      tags:
      - Ideas
      summary: Edit a comment or reply on an idea
      description: |
        Edits ONE comment — or one reply — on an idea: its body and its @mentions.

        **AUTHOR ONLY.** Only the comment's own author may edit it, with **no time
        window**. This is deliberately *narrower* than the `DELETE` on this same
        path, which also admits an Ideas admin: deleting is a moderation action the
        web grants admins, whereas editing would let an admin rewrite words that
        stay attributed to their original author. An Ideas admin who is not the
        author gets `403 forbidden` and the comment is untouched.

        Note the web Ideas thread has **no edit affordance at all**
        (`Apps::Ideas::CommentsController` implements only create + destroy), so
        this endpoint follows the platform's comment-edit precedent — WikiComment
        and News Feed both restrict editing to the author — minus their expiry
        window, matching how Ideas already declines to time-limit its deletes.

        The `can_edit` flag on every comment and reply returned by
        `GET /ideas/{idea_id}/comments` is the SAME decision, so a client can render
        the Edit affordance exactly where this call will succeed.

        **@mentions are re-derived from the new body.** Send the platform mention
        token `@[Name](mention:id)` inline, exactly as on create; the server
        re-parses the edited body and rewrites `mentioned_user_ids`, so a mention
        you add starts counting and one you delete stops. A token naming a user
        outside this business is dropped rather than stored. Only the users this
        edit NEWLY mentions are notified — fixing a typo never re-pings the thread —
        and **at most 25 of them**, queued rather than delivered inside this request
        (the same cap and the same queueing as `POST .../comments`; it bounds the
        notification only, never what is stored). Omit `body` entirely to leave the
        text (and therefore its mentions) untouched.

        **No attachments** — an idea comment is text + mentions only on every
        surface (matching the web composer, which has no file field), so there are
        no files to add or remove. A stray `attachments` param is ignored.

        A successful edit stamps `edited_at`, which the comment payload returns so a
        client can show an "edited" marker.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: The new text (leading/trailing whitespace trimmed,
                    like create). Mentions are re-derived from it. OMIT to leave the
                    body and its mentions unchanged; sending it blank is rejected
                    `422 body_required` rather than silently emptying the comment.
                  example: Revised — cc @[Ada Adminson](mention:42)
      responses:
        '200':
          description: Comment updated.
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/IdeaComment"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR the caller is not the comment's author (error code `forbidden`) — an
            Ideas admin who did not write it is refused here too. The comment is left
            untouched.
        '404':
          description: No such comment in this idea's thread within the caller's business
            (error code `not_found`). Covers an unknown id, a comment belonging to
            a DIFFERENT idea, a comment in another tenant, and an already-deleted
            comment — all report `404`, never `403`.
        '422':
          description: "`body_required` — `body` was sent but blank; `invalid` — the
            resulting comment failed validation; `edit_failed` — the write could not
            be completed. In every case the comment is left exactly as it was. Also
            `content_blocked` when the workspace's content-moderation policy refuses
            the submitted text; the message is the policy's own and is safe to show
            the author verbatim."
    delete:
      tags:
      - Ideas
      summary: Delete a comment or reply on an idea
      description: |
        Deletes ONE comment — or one reply — from an idea's discussion. The native
        mirror of the web `Apps::Ideas::CommentsController#destroy` (the **Delete**
        affordance on each comment in the thread).

        **AUTHOR OR IDEAS ADMIN.** The comment's own author may delete it, and so may
        an Ideas admin — a business admin-or-above, or a member holding the Ideas
        app-admin role. This is the web gate verbatim, and there is **no time window**
        (unlike the Wikis comment API's 5-minute edit/delete window). Anyone else gets
        `403 forbidden` and the comment is left untouched.

        Note this gate is *wider* than the sibling `DELETE /ideas/{id}`, which is
        author-only: removing a comment is a moderation action the web grants admins,
        whereas deleting an idea has no admin affordance at all. Authoring the **idea**
        grants nothing here — the gate reads the comment's author, so the idea's author
        cannot remove other people's comments from their own idea.

        The `can_delete` flag on every comment and reply returned by
        `GET /ideas/{idea_id}/comments` is the SAME decision, so a client can render the
        Delete affordance exactly where this call will succeed.

        **A soft delete.** `Platform::Comment` is soft-deletable: the row is retained
        with `deleted_at` (and `deleted_by_id`) stamped, and every read path filters it
        out — the web thread, this API's thread, and every comment count. It is gone as
        far as any user is concerned, and it is not restorable through the API.

        **Replies go with it.** Deleting a top-level comment also deletes its direct
        replies — the cascade the web's own confirm dialog promises ("Its replies will
        be removed too."). `replies_deleted` reports how many went. Replies that were
        already deleted are not re-stamped and are not counted. Deleting a **reply**
        touches only that reply: its parent and its siblings are untouched, and
        `replies_deleted` is `0` (threading is one level deep, so a reply has none).

        **Not idempotent.** A second call returns `404 not_found`, because the comment
        is genuinely gone from every scope the lookup can see. Clients should treat
        `404` on a retry as success.

        `total_comments` is the idea's discussion volume (comments + replies) **after**
        the delete, in the same key `GET /ideas/{idea_id}/comments` uses, so a client can
        patch the count it is already displaying without re-fetching the thread.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Comment deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - comment_id
                - parent_comment_id
                - deleted
                - replies_deleted
                - total_comments
                - message
                properties:
                  idea_id:
                    type: integer
                    description: The idea the comment belonged to.
                    example: 91
                  comment_id:
                    type: integer
                    description: The id of the comment that was deleted.
                    example: 5512
                  parent_comment_id:
                    type: integer
                    nullable: true
                    description: The deleted comment's parent — `null` when a top-level
                      comment was deleted, the parent's id when a reply was.
                    example:
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  replies_deleted:
                    type: integer
                    description: Direct replies deleted along with the comment (already-deleted
                      replies are not counted). Always 0 when the deleted record was
                      itself a reply.
                    example: 2
                  total_comments:
                    type: integer
                    description: The idea's remaining discussion volume — comments
                      + replies, the same number `GET /ideas/{idea_id}/comments` reports
                      and the feed card's `comments_count` shows.
                    example: 7
                  message:
                    type: string
                    description: "`Comment deleted.` for a top-level comment, `Reply
                      deleted.` for a reply."
                    example: Comment deleted.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR the caller is neither the comment's author nor an Ideas admin (error
            code `forbidden`). The comment is left untouched.
        '404':
          description: No such comment in this idea's thread within the caller's business
            (error code `not_found`). Covers an unknown id, a comment that belongs
            to a DIFFERENT idea, a comment in another tenant, and a comment that is
            already deleted — all report `404`, never `403`, so the response can't
            confirm that the id exists somewhere else.
        '422':
          description: The delete could not be completed (error code `delete_failed`);
            the comment and its replies survive.
  "/ideas/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The idea's id.
      schema:
        type: integer
        example: 91
    get:
      tags:
      - Ideas
      summary: Single idea detail
      description: |
        The full detail for ONE idea — the native mirror of the web detail page
        (`Apps::Ideas::IdeasController#show`). All loading runs through the shared
        `::Ideas::IdeaDetailQuery`, so the web and the API resolve the reviewer
        panel, the recent-voter set, the lifecycle and the comment count
        identically, in a fixed number of queries (no N+1).

        **Lifecycle** — every lifecycle stage of the workspace, in pipeline
        (`position`) order, each marked relative to where this idea sits:
        `done` (before the current stage), `current` (this idea's stage), or
        `upcoming` (after it). This is the strip the web renders.

        **Reviewer panel** — `group_name` is the panel that scores this idea (the
        idea's campaign group when it has one, else the workspace default — the
        web's "This idea is scored by …"). `reviewers` carries up to **3** members
        and is **OMITTED** entirely when the workspace turns "Show reviewer names"
        off; `reviewers_count` (the whole panel size) is always present so a client
        can still render "Review Panel (7)" exactly like the web.

        **Conditional fields**
        * `voting_closes_on` — present ONLY when the workspace enabled the
          voting-close-date feature AND this idea has a date set (key omitted
          otherwise, never null).
        * `rice_score` / `rice_score_band` — present only while RICE scoring is on
          for the workspace. The band is the colour the web paints the score.
        * `attachments` — `[]` when the workspace turned attachments off.

        **`recent_voters`** — the three most recent upvoters, newest first, each
        with the time they voted.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Idea detail retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - idea
                properties:
                  idea:
                    type: object
                    required:
                    - id
                    - title
                    - description
                    - description_html
                    - stage
                    - creator
                    - created_at
                    - vote_count
                    - has_voted
                    - voting_closed
                    - vote_change_allowed
                    - can_edit
                    - can_delete
                    - group_name
                    - campaign
                    - comments_count
                    - lifecycle
                    - reviewers_count
                    - recent_voters
                    - attachments
                    properties:
                      id:
                        type: integer
                        example: 91
                      title:
                        type: string
                        example: Add SSO for contractors
                      description:
                        type: string
                        nullable: true
                        description: The idea's plain-text body.
                        example: We should let contractors sign in with SSO.
                      description_html:
                        type: string
                        nullable: true
                        description: The rich-text body as stored (safe HTML).
                        example: "<p>We should let contractors sign in with SSO.</p>"
                      stage:
                        type: object
                        description: The idea's current lifecycle stage.
                        required:
                        - id
                        - name
                        - color
                        - category
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Reviewing
                          color:
                            type: string
                            description: The stage's hex colour.
                            example: "#2e63b3"
                          category:
                            type: string
                            enum:
                            - entry
                            - active
                            - implemented
                            - declined
                            example: active
                      creator:
                        type: object
                        required:
                        - id
                        - name
                        - photo
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 49290
                          name:
                            type: string
                            nullable: true
                            description: The author's full name.
                            example: Dana Lee
                          photo:
                            type: string
                            nullable: true
                            description: Absolute avatar URL (a ui-avatars initial
                              tile when the user has no photo).
                            example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-07-31T06:01:58Z'
                      vote_count:
                        type: integer
                        description: Positive votes (up_votes_count counter cache).
                        example: 12
                      has_voted:
                        type: boolean
                        description: Whether the CALLING user has upvoted this idea.
                        example: true
                      voting_closed:
                        type: boolean
                        description: True once the idea's voting close date has passed
                          — but only on a workspace where `close_date_enabled` is
                          true. A workspace that has the voting-close-date feature
                          off never reports a closed idea and never refuses a vote,
                          and `voting_closes_on` is withheld.
                        example: false
                      vote_change_allowed:
                        type: boolean
                        description: Whether the workspace lets a member remove their
                          upvote (drives the Remove affordance).
                        example: true
                      can_edit:
                        type: boolean
                        description: Whether the CALLING user may edit this idea.
                          True for the **author only** — the web gates edit on `set_own_idea`
                          (`Idea#mine?`) and there is deliberately **no admin override**
                          (its Manage dropdown renders solely under `mine?`). Drives
                          the Edit affordance.
                        example: false
                      can_delete:
                        type: boolean
                        description: Whether the CALLING user may delete this idea
                          — the same author-only rule as `can_edit`, and exactly what
                          `DELETE /ideas/{id}` enforces. Kept as a separate field
                          because it is a separate affordance.
                        example: false
                      rice_score:
                        type: integer
                        nullable: true
                        description: Cached RICE score (null when the idea has not
                          been scored). The key is OMITTED entirely when RICE scoring
                          is turned off for the workspace.
                        example: 42
                      rice_score_band:
                        type: object
                        description: 'The colour band `rice_score` renders as, so
                          a client paints the score pill the same way the web does
                          without hardcoding our ranges. `tier` is the stable key
                          to switch on: `high` (>= 700), `medium` (>= 300), `low`
                          below that, `none` when the idea is awaiting its first score.
                          Travels with `rice_score` and is OMITTED under the same
                          gate (RICE scoring off for the workspace).'
                        properties:
                          tier:
                            type: string
                            enum:
                            - high
                            - medium
                            - low
                            - none
                            example: high
                          label:
                            type: string
                            description: Human label for the band.
                            example: High
                          color:
                            type: string
                            description: Foreground hex the web uses.
                            example: "#146c43"
                          background:
                            type: string
                            description: Background hex the web uses; `transparent`
                              for the `none` band.
                            example: "#d1f0e0"
                      group_name:
                        type: string
                        nullable: true
                        description: Name of the reviewer panel that scores this idea
                          — the idea's campaign review group when it has one, else
                          the workspace default panel. null when no panel is configured.
                        example: Product Review Panel
                      campaign:
                        type: object
                        nullable: true
                        description: The campaign this idea was submitted to; null
                          when it stands alone.
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            example: 5
                          name:
                            type: string
                            example: Frontline Experience
                      comments_count:
                        type: integer
                        description: Non-deleted comments + replies.
                        example: 4
                      voting_closes_on:
                        type: string
                        format: date
                        description: The idea's voting close date (ISO `YYYY-MM-DD`).
                          Present ONLY when the workspace enabled the voting-close-date
                          feature AND this idea has a date set — the key is OMITTED
                          otherwise.
                        example: '2026-08-15'
                      lifecycle:
                        type: array
                        description: Every workspace lifecycle stage in pipeline order,
                          marked relative to this idea's current stage.
                        items:
                          type: object
                          required:
                          - stage_id
                          - name
                          - color
                          - position
                          - state
                          properties:
                            stage_id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Reviewing
                            color:
                              type: string
                              example: "#2e63b3"
                            position:
                              type: integer
                              description: 0-based pipeline position.
                              example: 1
                            state:
                              type: string
                              enum:
                              - done
                              - current
                              - upcoming
                              description: "`done` before the idea's stage, `current`
                                at it, `upcoming` after it."
                              example: current
                      reviewers:
                        type: array
                        description: Up to THREE members of the idea's reviewer panel.
                          OMITTED entirely when "Show reviewer names" is off for the
                          workspace (`reviewers_count` still reports the full panel
                          size).
                        items:
                          type: object
                          required:
                          - id
                          - name
                          - photo
                          properties:
                            id:
                              type: integer
                              example: 77
                            name:
                              type: string
                              nullable: true
                              example: Rae Reviewer
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL.
                              example: https://officechat.workforce.mangoapps.com/system/photos/77/thumb.png
                      reviewers_count:
                        type: integer
                        description: Total size of the reviewer panel (present even
                          when names are hidden).
                        example: 7
                      is_reviewer:
                        type: boolean
                        description: 'Whether the CALLER is on this idea''s review
                          panel — the panel its campaign sets, else the workspace
                          default. This is the web''s `can_review?` rule verbatim:
                          **panel membership is the only grant**, so an Ideas admin
                          who is not a member gets `false` (the web''s reviewer actions
                          carry no admin bypass either). Drives the reviewer-only
                          affordances a client renders — the stage mover, the RICE
                          scorer — and gates `audit_entries` below.'
                        example: true
                      audit_entries:
                        type: array
                        description: |-
                          The idea's audit trail, newest first, capped at 50 — **present only when `is_reviewer` is true**. The web renders its Audit Log panel behind the same gate: the trail names who moved a stage, what the RICE score was before, and reviewer notes, none of which a submitter is shown.
                          The key is OMITTED for a non-reviewer rather than sent empty: `[]` would read as "this idea has no history", which is a different claim from "not yours to see".
                          Each row carries the structured facts (not a pre-built sentence), so a client composes its own copy. `payload` is passed through verbatim so nothing the web can show is lost — a stage `note`, the per-field RICE `changes`, the `edits` list.
                        items:
                          type: object
                          required:
                          - id
                          - entry_type
                          - actor
                          - created_at
                          - icon
                          - color
                          - payload
                          properties:
                            id:
                              type: integer
                              example: 9013
                            entry_type:
                              type: string
                              description: What happened. `create` = the idea was
                                submitted.
                              enum:
                              - create
                              - stage
                              - score
                              - edit
                              example: stage
                            actor:
                              type: object
                              nullable: true
                              description: Who did it; null for a system entry.
                              properties:
                                id:
                                  type: integer
                                  example: 77
                                name:
                                  type: string
                                  example: Rae Reviewer
                                photo:
                                  type: string
                                  nullable: true
                                  description: Absolute avatar URL.
                            created_at:
                              type: string
                              format: date-time
                              example: '2026-07-31T09:30:28Z'
                            icon:
                              type: string
                              description: Font Awesome glyph for the entry type —
                                the same one the web uses, so both surfaces read identically.
                              example: fa-arrow-right-arrow-left
                            color:
                              type: string
                              description: Accent hex for the entry type (matches
                                the web).
                              example: "#2e63b3"
                            payload:
                              type: object
                              description: 'The raw, type-specific detail. `stage`
                                → `{from, to, note?}` (stage ids); `score` → `{score_from,
                                score_to, changes: [{field, from, to}]}`; `edit` →
                                `{edits: [...]}`; `create` → `{}`.'
                              additionalProperties: true
                              example:
                                from: 1
                                to: 2
                                note: Looks good
                            from_stage:
                              type: object
                              nullable: true
                              description: "`stage` entries ONLY (absent on other
                                types): the payload's `from` id resolved for rendering.
                                Null when the entry has no `from` (an entry INTO the
                                pipeline) or the stage has since been deleted."
                              properties:
                                id:
                                  type: integer
                                  example: 1
                                name:
                                  type: string
                                  example: New
                                color:
                                  type: string
                                  example: "#6c757d"
                            to_stage:
                              type: object
                              nullable: true
                              description: "`stage` entries ONLY: the payload's `to`
                                id resolved the same way."
                              properties:
                                id:
                                  type: integer
                                  example: 2
                                name:
                                  type: string
                                  example: Reviewing
                                color:
                                  type: string
                                  example: "#997404"
                      audit_entries_truncated:
                        type: boolean
                        description: |-
                          Whether `audit_entries` above may be only the NEWEST page of the trail. Present exactly when `audit_entries` is — i.e. only for a reviewer — so a flag never appears without the collection it describes.
                          The other two capped collections on this payload ship their real totals (`reviewers_count`, `vote_count`); the audit trail's true total is not available without an extra query, so it ships this flag instead. It is the same detector the web uses for its "Showing the 50 most recent entries." line, which means it is *possibly* truncated: an idea with exactly 50 entries reports `true`. It over-reports by at most one page and never under-reports, so a client may safely use it to decide whether to caveat the list.
                        example: false
                      recent_voters:
                        type: array
                        description: The three most recent upvoters, newest first.
                        items:
                          type: object
                          required:
                          - id
                          - name
                          - photo
                          - voted_at
                          properties:
                            id:
                              type: integer
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL.
                            voted_at:
                              type: string
                              format: date-time
                              description: When this person upvoted.
                              example: '2026-07-31T06:15:30Z'
                      attachments:
                        type: array
                        description: Files attached to the idea. `[]` when the workspace
                          turned attachments off.
                        items:
                          type: object
                          required:
                          - id
                          - filename
                          - content_type
                          - byte_size
                          - url
                          properties:
                            id:
                              type: integer
                              example: 2637
                            filename:
                              type: string
                              example: proposal.pdf
                            content_type:
                              type: string
                              nullable: true
                              example: application/pdf
                            byte_size:
                              type: integer
                              example: 88877
                            url:
                              type: string
                              description: Absolute download URL (Content-Disposition
                                attachment).
                              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJ.../proposal.pdf?disposition=attachment
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
    patch:
      tags:
      - Ideas
      summary: Edit an idea
      description: |
        Edits an idea — the native mirror of the web Manage ▸ Edit drawer
        (`Apps::Ideas::IdeasController#update`), sharing
        `Ideas::IdeaUpdateService`, `Ideas::AttachmentScreener` and
        `Ideas::DescriptionHtml` so the two surfaces behave identically.

        **PATCH semantics** — every field is optional and a key you do NOT send is left
        unchanged, so editing the title can never blank the description. The web always
        posts its whole form, so sending everything behaves the same.

        **The campaign is never silently detached.** `campaign_id` is applied only when
        you send it; omitting it keeps the current campaign. The web learned this the
        hard way — its campaign select renders only when an open campaign exists, so an
        unconditional assign detached ideas whose campaign had since closed, via a
        field the author could not even see.

        **Attachments are ADDITIVE and gated.** `files[]` / `file_signed_ids[]` are
        accepted only while **"Allow file attachments"** is ON; when OFF they are
        dropped and the edit still applies. An edit never replaces or removes an
        existing file — the web has no attachment-removal path at all. The attach runs
        **inside the same transaction as the save**, so a failed attach rolls the edit
        back rather than committing a half-applied change. Anything dropped is reported
        per-file in `attachment_errors` and repeated in `warnings`.

        **`warnings` names every field this workspace's settings discarded**, on a 200
        that applied only part of the edit: a dropped file, a `voting_closes_on` the
        close-date toggle refused (whether you were setting one or clearing one), a
        `campaign_id` the campaigns toggle refused. Omitted entirely when nothing was
        dropped.

        **Authorization: the AUTHOR only** (`Idea#mine?`), with deliberately no admin
        override — the web's Manage dropdown renders solely under `mine?`, so an admin
        gets `403` too. An id in another tenant returns `404`.

        The edit is **audited with a diff** (`Ideas::AuditLogger.log_edit!`) and the
        recorded `edits` are echoed back: a title's from/to, that the description
        changed (without dumping the body), a moved close date, and a campaign move by
        name.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        description: "`application/json`, or `multipart/form-data` when sending `files[]`."
        content:
          application/json:
            schema:
              type: object
              description: Send only the fields you want to change.
              properties:
                title:
                  type: string
                  maxLength: 200
                  description: Replaces the title. Whitespace is stripped; blank is
                    rejected (422).
                  example: Dark mode for the mobile app (revised)
                description:
                  type: string
                  description: Replaces the body; `description_html` is re-derived
                    (escaped, then formatted).
                campaign_id:
                  type: integer
                  nullable: true
                  description: MOVES the idea to this campaign. Honoured only while
                    Campaigns is on. Omitting it never detaches the current campaign.
                    Must belong to this business and be open, else `422`.
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                  description: Sets the voting close date; honoured only while that
                    feature is on. Send it EMPTY (`""` or `null`) to CLEAR the date
                    — that is how the web clears it.
          multipart/form-data:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: string
                campaign_id:
                  type: integer
                  nullable: true
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                files[]:
                  type: array
                  description: Files to ADD. Accepted only while "Allow file attachments"
                    is ON; otherwise dropped (the edit still applies). Screened against
                    a size cap and a sniffed content-type allowlist.
                  items:
                    type: string
                    format: binary
                file_signed_ids[]:
                  type: array
                  description: ActiveStorage signed ids, for clients that direct-upload
                    first. Re-screened server-side; an already-attached blob is rejected.
                  items:
                    type: string
      responses:
        '200':
          description: Idea updated
          content:
            application/json:
              schema:
                type: object
                required:
                - idea
                - edits
                - attachment_errors
                properties:
                  idea:
                    type: object
                    description: The updated idea, in the SAME shape `GET /api/v1/ideas/{id}`
                      returns — one query object and one serializer back both.
                  edits:
                    type: array
                    description: The audit diff that was recorded. Empty when nothing
                      actually changed. `Description` carries no from/to — the trail
                      is a summary, not a copy of the body.
                    items:
                      type: object
                      properties:
                        field:
                          type: string
                          example: Title
                        from:
                          type: string
                          nullable: true
                          example: Dark mode
                        to:
                          type: string
                          nullable: true
                          example: Dark mode (revised)
                    example:
                    - field: Title
                      from: Dark mode
                      to: Dark mode (revised)
                    - field: Description
                  attachment_errors:
                    type: array
                    description: One reason per file that was NOT attached — wrong
                      type, over the size cap, or the workspace's "Allow file attachments"
                      setting being off. Always present; empty when everything attached.
                      Never fatal.
                    items:
                      type: string
                  warnings:
                    type: array
                    description: One sentence per field this workspace's settings
                      discarded (a dropped file, a refused `voting_closes_on`, a refused
                      `campaign_id`). OMITTED when nothing was dropped, so the key's
                      presence is the signal that the 200 is a partial write.
                    items:
                      type: string
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is not the idea's author (error code `forbidden`
            — the message says "edit"), or the Ideas app is not accessible (`access_denied`).
        '404':
          description: No idea with that id in this business (error code `not_found`).
        '422':
          description: Validation failed (error code `invalid`) — a blank title, a
            title over 200 characters, or a campaign that is closed or belongs to
            another business. Also `content_blocked` when the workspace's content-moderation
            policy refuses the submitted text; the message is the policy's own and
            is safe to show the author verbatim.
    delete:
      tags:
      - Ideas
      summary: Delete an idea
      description: |
        Permanently deletes an idea — the native mirror of the web
        `Apps::Ideas::IdeasController#destroy` (the Manage ▸ **Delete idea** action
        on the idea detail page).

        **AUTHOR-ONLY.** Only the user who submitted the idea may delete it. This is
        the web rule verbatim (`Idea#mine?`): the web renders its Manage dropdown
        solely for the author, so **there is no admin override** — a workspace admin
        who did not write the idea gets `403 forbidden`, same as any other member.
        Note this is stricter than reading: `GET /ideas/{id}` is open to every member
        of the business.

        **What the delete removes** (all inside ONE transaction — a failure rolls
        the whole thing back and the idea survives intact):
        * the idea row itself, and its ActiveStorage attachments (blobs purged),
        * its votes — hard-deleted,
        * its audit-trail entries — hard-deleted,
        * its comments and threaded replies — **soft-deleted** (`deleted_at`
          stamped). The rows are retained, exactly as the web's cascade leaves them,
          because `Platform::Comment` is soft-deletable. They disappear from every
          read either way.

        The idea's **campaign is not affected** — only the idea leaves it.

        **Not idempotent.** A second call returns `404 not_found`, because the idea
        is genuinely gone. Clients should treat `404` on a retry as success.

        `votes_deleted` and `comments_deleted` report what the cascade actually
        touched, so a client can confirm the destructive scope it warned the user
        about ("its votes and comments will be permanently removed").
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Idea deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - deleted
                - votes_deleted
                - comments_deleted
                - message
                properties:
                  idea_id:
                    type: integer
                    description: The id of the idea that was deleted.
                    example: 91
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  votes_deleted:
                    type: integer
                    description: Votes removed with the idea.
                    example: 12
                  comments_deleted:
                    type: integer
                    description: Comments and replies trashed with the idea.
                    example: 4
                  message:
                    type: string
                    example: Idea deleted.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR the caller is not the idea's author (error code `forbidden`). The idea
            is left untouched.
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
            An idea belonging to another tenant reports `404`, never `403`, so the
            response can't confirm that the id exists elsewhere.
        '422':
          description: The delete could not be completed and was rolled back (error
            code `delete_failed`); the idea and everything attached to it survive.
  "/training/my_training":
    get:
      tags:
      - Training
      summary: My Training home
      description: |
        The "My Training" home header — the native mirror of the top of the
        learner dashboard. Returns the three pieces of chrome above the course
        list (the list itself is the paginated `/training/my_training/enrollments`
        endpoint): the filter-tab counts, the overdue banner, and the "Continue
        where you left off" cards. Strictly scoped to the caller.

        **Sections:**
        * `counts` — badges for the All / In Progress / Assigned / Completed tabs
          plus the Overdue banner. All but `all` OVERLAP and match the web
          dashboard chip math: in-progress/assigned/overdue are measured over the
          active set (an assigned, in-progress course counts in both In Progress
          and Assigned), completed over the completed set. `all` is their union —
          every enrollment except cancelled.
        * `overdue` — `count` plus the most-overdue course/path rows, so the
          client can render the banner ("<title> is <n> days overdue").
        * `continue_learning` — up to 4 recently-accessed active COURSE
          enrollments (newest access first), each a card that additionally
          carries `next_lesson` (the lesson to resume into — the prototype's
          "Next: …" line).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: My Training home retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - my_training
                properties:
                  my_training:
                    type: object
                    required:
                    - counts
                    - overdue
                    - continue_learning
                    properties:
                      counts:
                        type: object
                        description: Badges for the All / In Progress / Assigned /
                          Completed tabs + Overdue banner. All but `all` OVERLAP (an
                          assigned, in-progress course counts in both In Progress
                          and Assigned). `all` is the union of the others — every
                          enrollment except cancelled — and equals `meta.total_count`
                          on `/training/my_training/enrollments?status=all`.
                        required:
                        - all
                        - in_progress
                        - assigned
                        - completed
                        - overdue
                        properties:
                          all:
                            type: integer
                            example: 8
                          in_progress:
                            type: integer
                            example: 6
                          assigned:
                            type: integer
                            example: 3
                          completed:
                            type: integer
                            example: 2
                          overdue:
                            type: integer
                            example: 2
                      overdue:
                        type: object
                        required:
                        - count
                        - items
                        properties:
                          count:
                            type: integer
                            example: 2
                          items:
                            type: array
                            description: Most-overdue first; drives the banner text.
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 4412
                                type:
                                  type: string
                                  enum:
                                  - course
                                  - path
                                  example: course
                                title:
                                  type: string
                                  example: Product Knowledge
                                days_overdue:
                                  type: integer
                                  example: 15
                      continue_learning:
                        type: array
                        description: Up to 4 recently-accessed active COURSE cards,
                          newest access first. Course-only by construction, so the
                          card's path-only fields (completed_steps / total_steps /
                          total_courses / current_step) never appear here; `next_lesson`
                          appears ONLY here (the list endpoint omits it).
                        items:
                          type: object
                          description: One course or path enrollment card. `type`
                            selects which progress fields are present.
                          required:
                          - id
                          - type
                          - status
                          - in_progress
                          - assigned
                          - completed
                          - overdue
                          - progress_percentage
                          - subject
                          properties:
                            id:
                              type: integer
                              example: 4412
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            status:
                              type: string
                              description: Raw enrollment status column.
                              enum:
                              - enrolled
                              - in_progress
                              - completed
                              - cancelled
                              example: in_progress
                            status_key:
                              type: string
                              description: Learner-facing state (Training::Display)
                                — note a raw `enrolled` reads as not_started, exactly
                                as the web badge does.
                              enum:
                              - not_started
                              - in_progress
                              - completed
                              - registered
                              example: in_progress
                            status_label:
                              type: string
                              description: Display label for status_key (In Progress
                                / Not Started / Completed / Registered).
                              example: In Progress
                            in_progress:
                              type: boolean
                              example: true
                            assigned:
                              type: boolean
                              description: Assigned by an admin/manager/rule (renders
                                the ASSIGNED chip).
                              example: true
                            completed:
                              type: boolean
                              example: false
                            overdue:
                              type: boolean
                              example: false
                            progress_percentage:
                              type: integer
                              description: 0–100.
                              example: 50
                            due_date:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2026-08-15T00:00:00Z'
                            days_overdue:
                              type: integer
                              nullable: true
                              description: Present only when overdue.
                              example:
                            completed_at:
                              type: string
                              format: date-time
                              nullable: true
                              example:
                            last_accessed_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: Courses only.
                              example: '2026-08-12T14:03:00Z'
                            completed_lessons_count:
                              type: integer
                              description: Course cards only. Counted WITHIN `total_lessons`
                                — completions for lessons outside the learner's pinned
                                version are excluded, so this can never exceed the
                                total.
                              example: 3
                            total_lessons:
                              type: integer
                              description: Course cards only. The lesson count of
                                the version the learner is PINNED to (falling back
                                to the current published version when not yet pinned)
                                — not the course's lifetime lesson count across every
                                version. Matches the row count of `/training/courses/{id}/lessons`
                                for the same learner.
                              example: 6
                            next_lesson:
                              type: object
                              nullable: true
                              description: Course cards only, and only in Continue-Learning
                                — the lesson to resume into.
                              properties:
                                id:
                                  type: integer
                                  example: 91
                                title:
                                  type: string
                                  example: Lockout/Tagout Procedure
                            completed_steps:
                              type: integer
                              description: Path cards only.
                              example: 1
                            total_steps:
                              type: integer
                              description: Path cards only.
                              example: 3
                            total_courses:
                              type: integer
                              description: Path cards only — courses in the path.
                              example: 8
                            current_step:
                              type: integer
                              description: Path cards only — 1-based "Step N of M"
                                (caps at total_steps).
                              example: 2
                            subject:
                              type: object
                              nullable: true
                              description: The course or path. Null for a hidden SYSTEM
                                course ("course unavailable").
                              required:
                              - id
                              - title
                              - type
                              - delivery_label
                              properties:
                                id:
                                  type: integer
                                  example: 320
                                title:
                                  type: string
                                  example: Workplace Safety Fundamentals
                                slug:
                                  type: string
                                  nullable: true
                                  example: workplace-safety-fundamentals
                                type:
                                  type: string
                                  enum:
                                  - course
                                  - path
                                  example: course
                                delivery_label:
                                  type: string
                                  description: Self-paced / Instructor-Led / Learning
                                    Path.
                                  enum:
                                  - Self-paced
                                  - Instructor-Led
                                  - Learning Path
                                  example: Self-paced
                                delivery_icon:
                                  type: string
                                  description: Font Awesome glyph for the delivery
                                    type — the web banner's type fallback (fa-circle-play
                                    / fa-chalkboard-user / fa-route).
                                  example: fa-circle-play
                                category:
                                  type: object
                                  nullable: true
                                  properties:
                                    name:
                                      type: string
                                      example: Safety
                                    color:
                                      type: string
                                      nullable: true
                                      description: Category color (hex).
                                      example: "#28a745"
                                    icon:
                                      type: string
                                      nullable: true
                                      description: Font Awesome glyph.
                                      example: fa-shield-alt
                                hero_image_url:
                                  type: string
                                  nullable: true
                                  description: Absolute URL of a resized hero image,
                                    or null.
                                  example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
  "/training/my_training/enrollments":
    get:
      tags:
      - Training
      summary: My Training course + path list
      description: |
        The "My Training" list — course AND training-path enrollments merged into
        one feed, newest enrollment first, matching the prototype's mixed list.
        Serves the segmented filter: All / In Progress / Assigned / Completed /
        Overdue. Every `status` value takes the same `page`/`per_page` contract
        and returns the same `enrollments` + `counts` + `meta` shape, so a client
        can back any set of tabs with one request shape.

        `counts` badges the tabs + overdue banner (same math as
        `/training/my_training`). `meta` drives pagination. Each row is a
        `TrainingCard` — a `type: course` card carries `completed_lessons_count`/
        `total_lessons`; a `type: path` card carries `completed_steps`/
        `total_steps`/`total_courses`/`current_step` (the prototype's
        "Step 2 of 3 · 8 courses").
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Which tab to load. Unknown values resolve to `in_progress`. `all`
          is the learner's WHOLE list — the union of every other value (every enrollment
          except cancelled, which is withdrawn audit state) — so it can never come
          back smaller than one of them. `overdue` is past-due and not completed.
          Every value takes the same `page`/`per_page` contract and returns the same
          `enrollments` + `counts` + `meta` shape.
        schema:
          type: string
          enum:
          - in_progress
          - assigned
          - completed
          - overdue
          - all
          default: in_progress
          example: in_progress
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Enrollment list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - enrollments
                - active_status
                - counts
                - meta
                properties:
                  enrollments:
                    type: array
                    items:
                      type: object
                      description: One course or path enrollment card. `type` selects
                        which progress fields are present.
                      required:
                      - id
                      - type
                      - status
                      - in_progress
                      - assigned
                      - completed
                      - overdue
                      - progress_percentage
                      - subject
                      properties:
                        id:
                          type: integer
                          example: 4412
                        type:
                          type: string
                          enum:
                          - course
                          - path
                          example: course
                        status:
                          type: string
                          description: Raw enrollment status column.
                          enum:
                          - enrolled
                          - in_progress
                          - completed
                          - cancelled
                          example: in_progress
                        status_key:
                          type: string
                          description: Learner-facing state (Training::Display) —
                            note a raw `enrolled` reads as not_started, exactly as
                            the web badge does.
                          enum:
                          - not_started
                          - in_progress
                          - completed
                          - registered
                          example: in_progress
                        status_label:
                          type: string
                          description: Display label for status_key (In Progress /
                            Not Started / Completed / Registered).
                          example: In Progress
                        in_progress:
                          type: boolean
                          example: true
                        assigned:
                          type: boolean
                          description: Assigned by an admin/manager/rule (renders
                            the ASSIGNED chip).
                          example: true
                        completed:
                          type: boolean
                          example: false
                        overdue:
                          type: boolean
                          example: false
                        progress_percentage:
                          type: integer
                          description: 0–100.
                          example: 50
                        due_date:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-08-15T00:00:00Z'
                        days_overdue:
                          type: integer
                          nullable: true
                          description: Present only when overdue.
                          example:
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          example:
                        last_accessed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Courses only.
                          example: '2026-08-12T14:03:00Z'
                        completed_lessons_count:
                          type: integer
                          description: Course cards only. Counted WITHIN `total_lessons`
                            — completions for lessons outside the learner's pinned
                            version are excluded, so this can never exceed the total.
                          example: 3
                        total_lessons:
                          type: integer
                          description: Course cards only. The lesson count of the
                            version the learner is PINNED to (falling back to the
                            current published version when not yet pinned) — not the
                            course's lifetime lesson count across every version. Matches
                            the row count of `/training/courses/{id}/lessons` for
                            the same learner.
                          example: 6
                        next_lesson:
                          type: object
                          nullable: true
                          description: Course cards only, and only in Continue-Learning
                            — the lesson to resume into.
                          properties:
                            id:
                              type: integer
                              example: 91
                            title:
                              type: string
                              example: Lockout/Tagout Procedure
                        completed_steps:
                          type: integer
                          description: Path cards only.
                          example: 1
                        total_steps:
                          type: integer
                          description: Path cards only.
                          example: 3
                        total_courses:
                          type: integer
                          description: Path cards only — courses in the path.
                          example: 8
                        current_step:
                          type: integer
                          description: Path cards only — 1-based "Step N of M" (caps
                            at total_steps).
                          example: 2
                        subject:
                          type: object
                          nullable: true
                          description: The course or path. Null for a hidden SYSTEM
                            course ("course unavailable").
                          required:
                          - id
                          - title
                          - type
                          - delivery_label
                          properties:
                            id:
                              type: integer
                              example: 320
                            title:
                              type: string
                              example: Workplace Safety Fundamentals
                            slug:
                              type: string
                              nullable: true
                              example: workplace-safety-fundamentals
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            delivery_label:
                              type: string
                              description: Self-paced / Instructor-Led / Learning
                                Path.
                              enum:
                              - Self-paced
                              - Instructor-Led
                              - Learning Path
                              example: Self-paced
                            delivery_icon:
                              type: string
                              description: Font Awesome glyph for the delivery type
                                — the web banner's type fallback (fa-circle-play /
                                fa-chalkboard-user / fa-route).
                              example: fa-circle-play
                            category:
                              type: object
                              nullable: true
                              properties:
                                name:
                                  type: string
                                  example: Safety
                                color:
                                  type: string
                                  nullable: true
                                  description: Category color (hex).
                                  example: "#28a745"
                                icon:
                                  type: string
                                  nullable: true
                                  description: Font Awesome glyph.
                                  example: fa-shield-alt
                            hero_image_url:
                              type: string
                              nullable: true
                              description: Absolute URL of a resized hero image, or
                                null.
                              example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  active_status:
                    type: string
                    enum:
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    - all
                    example: in_progress
                  counts:
                    type: object
                    description: Badges for the All / In Progress / Assigned / Completed
                      tabs + Overdue banner. All but `all` OVERLAP (an assigned, in-progress
                      course counts in both In Progress and Assigned). `all` is the
                      union of the others — every enrollment except cancelled — and
                      equals `meta.total_count` on `/training/my_training/enrollments?status=all`.
                    required:
                    - all
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    properties:
                      all:
                        type: integer
                        example: 8
                      in_progress:
                        type: integer
                        example: 6
                      assigned:
                        type: integer
                        example: 3
                      completed:
                        type: integer
                        example: 2
                      overdue:
                        type: integer
                        example: 2
                  meta:
                    type: object
                    description: Pagination over the active filter (build_pagination_meta).
                    properties:
                      total_count:
                        type: integer
                        example: 6
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
  "/training/courses/{course_id}/enroll":
    post:
      tags:
      - Training
      summary: Self-enroll in a course
      description: |
        The action behind the catalog's own `enroll` and `start_path` CTAs
        (`cta_action` on a catalog card), which named an operation that had no
        native endpoint until now.

        ONE operation, two routes — `/training/courses/{course_id}/enroll` and
        `/training/learning_paths/{learning_path_id}/enroll` — resolved from
        whichever id the route carried, the same shape the reviews and Q&A
        writes use. No request body.

        **IDEMPOTENT.** A repeat (double-tapped button, retried request on a
        flaky connection) returns `200` with `created: false` and
        `already_enrolled: true`, NOT an error — so a client may retry safely.
        `created` is the flag to branch on; the accompanying `message` is
        already worded for either case.

        **Both self-enrollment switches must be on**, exactly as every
        learner-facing surface reads them: the tenant-wide Training setting AND
        the per-subject admin toggle. Note the two subject defaults differ — a
        course allows self-enrollment unless an admin turns it off, a learning
        path denies it unless an admin turns it on — so the same tenant can
        legitimately answer 200 for a course and 403 for a path.

        **Courses additionally enforce prerequisites**; learning paths have no
        prerequisites concept, so that refusal cannot occur for a path. The 422
        carries the blocking courses in `error.details.missing_prerequisites`
        so the client can name them rather than render a bare refusal.

        Enrolling in a PATH creates only the path enrollment — its member
        courses enroll lazily when the learner opens them, so do not expect
        course rows to appear in `/training/my_training/enrollments` yet.

        The returned `enrollment` is the same `TrainingCard` the My Training
        list renders, so a client can insert it into that list without a
        refetch.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        description: The course to enroll in. Digit-constrained.
        schema:
          type: integer
          example: 128
      responses:
        '200':
          description: 'Enrolled, or already enrolled — branch on `created`.

            '
          content:
            application/json:
              schema:
                type: object
                required:
                - created
                - already_enrolled
                - message
                - enrollment
                properties:
                  created:
                    type: boolean
                    description: True when this request created the enrollment; false
                      on an idempotent repeat.
                    example: true
                  already_enrolled:
                    type: boolean
                    description: The inverse of `created`, for a client that reads
                      the state rather than the event.
                    example: false
                  message:
                    type: string
                    description: Learner-facing copy, already worded for the created
                      / already-enrolled case.
                    example: Successfully enrolled!
                  enrollment:
                    type: object
                    description: One course or path enrollment card. `type` selects
                      which progress fields are present.
                    required:
                    - id
                    - type
                    - status
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    - progress_percentage
                    - subject
                    properties:
                      id:
                        type: integer
                        example: 4412
                      type:
                        type: string
                        enum:
                        - course
                        - path
                        example: course
                      status:
                        type: string
                        description: Raw enrollment status column.
                        enum:
                        - enrolled
                        - in_progress
                        - completed
                        - cancelled
                        example: in_progress
                      status_key:
                        type: string
                        description: Learner-facing state (Training::Display) — note
                          a raw `enrolled` reads as not_started, exactly as the web
                          badge does.
                        enum:
                        - not_started
                        - in_progress
                        - completed
                        - registered
                        example: in_progress
                      status_label:
                        type: string
                        description: Display label for status_key (In Progress / Not
                          Started / Completed / Registered).
                        example: In Progress
                      in_progress:
                        type: boolean
                        example: true
                      assigned:
                        type: boolean
                        description: Assigned by an admin/manager/rule (renders the
                          ASSIGNED chip).
                        example: true
                      completed:
                        type: boolean
                        example: false
                      overdue:
                        type: boolean
                        example: false
                      progress_percentage:
                        type: integer
                        description: 0–100.
                        example: 50
                      due_date:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-08-15T00:00:00Z'
                      days_overdue:
                        type: integer
                        nullable: true
                        description: Present only when overdue.
                        example:
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example:
                      last_accessed_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Courses only.
                        example: '2026-08-12T14:03:00Z'
                      completed_lessons_count:
                        type: integer
                        description: Course cards only. Counted WITHIN `total_lessons`
                          — completions for lessons outside the learner's pinned version
                          are excluded, so this can never exceed the total.
                        example: 3
                      total_lessons:
                        type: integer
                        description: Course cards only. The lesson count of the version
                          the learner is PINNED to (falling back to the current published
                          version when not yet pinned) — not the course's lifetime
                          lesson count across every version. Matches the row count
                          of `/training/courses/{id}/lessons` for the same learner.
                        example: 6
                      next_lesson:
                        type: object
                        nullable: true
                        description: Course cards only, and only in Continue-Learning
                          — the lesson to resume into.
                        properties:
                          id:
                            type: integer
                            example: 91
                          title:
                            type: string
                            example: Lockout/Tagout Procedure
                      completed_steps:
                        type: integer
                        description: Path cards only.
                        example: 1
                      total_steps:
                        type: integer
                        description: Path cards only.
                        example: 3
                      total_courses:
                        type: integer
                        description: Path cards only — courses in the path.
                        example: 8
                      current_step:
                        type: integer
                        description: Path cards only — 1-based "Step N of M" (caps
                          at total_steps).
                        example: 2
                      subject:
                        type: object
                        nullable: true
                        description: The course or path. Null for a hidden SYSTEM
                          course ("course unavailable").
                        required:
                        - id
                        - title
                        - type
                        - delivery_label
                        properties:
                          id:
                            type: integer
                            example: 320
                          title:
                            type: string
                            example: Workplace Safety Fundamentals
                          slug:
                            type: string
                            nullable: true
                            example: workplace-safety-fundamentals
                          type:
                            type: string
                            enum:
                            - course
                            - path
                            example: course
                          delivery_label:
                            type: string
                            description: Self-paced / Instructor-Led / Learning Path.
                            enum:
                            - Self-paced
                            - Instructor-Led
                            - Learning Path
                            example: Self-paced
                          delivery_icon:
                            type: string
                            description: Font Awesome glyph for the delivery type
                              — the web banner's type fallback (fa-circle-play / fa-chalkboard-user
                              / fa-route).
                            example: fa-circle-play
                          category:
                            type: object
                            nullable: true
                            properties:
                              name:
                                type: string
                                example: Safety
                              color:
                                type: string
                                nullable: true
                                description: Category color (hex).
                                example: "#28a745"
                              icon:
                                type: string
                                nullable: true
                                description: Font Awesome glyph.
                                example: fa-shield-alt
                          hero_image_url:
                            type: string
                            nullable: true
                            description: Absolute URL of a resized hero image, or
                              null.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `write:training` scope (`insufficient_permissions`); or
            self-enrollment is off (`self_enrollment_disabled`) — the message
            names WHICH switch, since one is a tenant setting an admin controls
            and the other is a property of the course or path.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            Prerequisites are not met (`prerequisites_not_met`, courses only —
            `error.details.missing_prerequisites` lists the blocking courses), or
            the enrollment could not be written (`enrollment_failed`).
  "/training/learning_paths/{learning_path_id}/enroll":
    post:
      tags:
      - Training
      summary: Self-enroll in a learning path
      description: |
        The action behind the catalog's own `enroll` and `start_path` CTAs
        (`cta_action` on a catalog card), which named an operation that had no
        native endpoint until now.

        ONE operation, two routes — `/training/courses/{course_id}/enroll` and
        `/training/learning_paths/{learning_path_id}/enroll` — resolved from
        whichever id the route carried, the same shape the reviews and Q&A
        writes use. No request body.

        **IDEMPOTENT.** A repeat (double-tapped button, retried request on a
        flaky connection) returns `200` with `created: false` and
        `already_enrolled: true`, NOT an error — so a client may retry safely.
        `created` is the flag to branch on; the accompanying `message` is
        already worded for either case.

        **Both self-enrollment switches must be on**, exactly as every
        learner-facing surface reads them: the tenant-wide Training setting AND
        the per-subject admin toggle. Note the two subject defaults differ — a
        course allows self-enrollment unless an admin turns it off, a learning
        path denies it unless an admin turns it on — so the same tenant can
        legitimately answer 200 for a course and 403 for a path.

        **Courses additionally enforce prerequisites**; learning paths have no
        prerequisites concept, so that refusal cannot occur for a path. The 422
        carries the blocking courses in `error.details.missing_prerequisites`
        so the client can name them rather than render a bare refusal.

        Enrolling in a PATH creates only the path enrollment — its member
        courses enroll lazily when the learner opens them, so do not expect
        course rows to appear in `/training/my_training/enrollments` yet.

        The returned `enrollment` is the same `TrainingCard` the My Training
        list renders, so a client can insert it into that list without a
        refetch.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        description: The learning path to start. Digit-constrained.
        schema:
          type: integer
          example: 6
      responses:
        '200':
          description: 'Enrolled, or already enrolled — branch on `created`.

            '
          content:
            application/json:
              schema:
                type: object
                required:
                - created
                - already_enrolled
                - message
                - enrollment
                properties:
                  created:
                    type: boolean
                    description: True when this request created the enrollment; false
                      on an idempotent repeat.
                    example: true
                  already_enrolled:
                    type: boolean
                    description: The inverse of `created`, for a client that reads
                      the state rather than the event.
                    example: false
                  message:
                    type: string
                    description: Learner-facing copy, already worded for the created
                      / already-enrolled case.
                    example: Successfully enrolled!
                  enrollment:
                    type: object
                    description: One course or path enrollment card. `type` selects
                      which progress fields are present.
                    required:
                    - id
                    - type
                    - status
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    - progress_percentage
                    - subject
                    properties:
                      id:
                        type: integer
                        example: 4412
                      type:
                        type: string
                        enum:
                        - course
                        - path
                        example: course
                      status:
                        type: string
                        description: Raw enrollment status column.
                        enum:
                        - enrolled
                        - in_progress
                        - completed
                        - cancelled
                        example: in_progress
                      status_key:
                        type: string
                        description: Learner-facing state (Training::Display) — note
                          a raw `enrolled` reads as not_started, exactly as the web
                          badge does.
                        enum:
                        - not_started
                        - in_progress
                        - completed
                        - registered
                        example: in_progress
                      status_label:
                        type: string
                        description: Display label for status_key (In Progress / Not
                          Started / Completed / Registered).
                        example: In Progress
                      in_progress:
                        type: boolean
                        example: true
                      assigned:
                        type: boolean
                        description: Assigned by an admin/manager/rule (renders the
                          ASSIGNED chip).
                        example: true
                      completed:
                        type: boolean
                        example: false
                      overdue:
                        type: boolean
                        example: false
                      progress_percentage:
                        type: integer
                        description: 0–100.
                        example: 50
                      due_date:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-08-15T00:00:00Z'
                      days_overdue:
                        type: integer
                        nullable: true
                        description: Present only when overdue.
                        example:
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example:
                      last_accessed_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Courses only.
                        example: '2026-08-12T14:03:00Z'
                      completed_lessons_count:
                        type: integer
                        description: Course cards only. Counted WITHIN `total_lessons`
                          — completions for lessons outside the learner's pinned version
                          are excluded, so this can never exceed the total.
                        example: 3
                      total_lessons:
                        type: integer
                        description: Course cards only. The lesson count of the version
                          the learner is PINNED to (falling back to the current published
                          version when not yet pinned) — not the course's lifetime
                          lesson count across every version. Matches the row count
                          of `/training/courses/{id}/lessons` for the same learner.
                        example: 6
                      next_lesson:
                        type: object
                        nullable: true
                        description: Course cards only, and only in Continue-Learning
                          — the lesson to resume into.
                        properties:
                          id:
                            type: integer
                            example: 91
                          title:
                            type: string
                            example: Lockout/Tagout Procedure
                      completed_steps:
                        type: integer
                        description: Path cards only.
                        example: 1
                      total_steps:
                        type: integer
                        description: Path cards only.
                        example: 3
                      total_courses:
                        type: integer
                        description: Path cards only — courses in the path.
                        example: 8
                      current_step:
                        type: integer
                        description: Path cards only — 1-based "Step N of M" (caps
                          at total_steps).
                        example: 2
                      subject:
                        type: object
                        nullable: true
                        description: The course or path. Null for a hidden SYSTEM
                          course ("course unavailable").
                        required:
                        - id
                        - title
                        - type
                        - delivery_label
                        properties:
                          id:
                            type: integer
                            example: 320
                          title:
                            type: string
                            example: Workplace Safety Fundamentals
                          slug:
                            type: string
                            nullable: true
                            example: workplace-safety-fundamentals
                          type:
                            type: string
                            enum:
                            - course
                            - path
                            example: course
                          delivery_label:
                            type: string
                            description: Self-paced / Instructor-Led / Learning Path.
                            enum:
                            - Self-paced
                            - Instructor-Led
                            - Learning Path
                            example: Self-paced
                          delivery_icon:
                            type: string
                            description: Font Awesome glyph for the delivery type
                              — the web banner's type fallback (fa-circle-play / fa-chalkboard-user
                              / fa-route).
                            example: fa-circle-play
                          category:
                            type: object
                            nullable: true
                            properties:
                              name:
                                type: string
                                example: Safety
                              color:
                                type: string
                                nullable: true
                                description: Category color (hex).
                                example: "#28a745"
                              icon:
                                type: string
                                nullable: true
                                description: Font Awesome glyph.
                                example: fa-shield-alt
                          hero_image_url:
                            type: string
                            nullable: true
                            description: Absolute URL of a resized hero image, or
                              null.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `write:training` scope (`insufficient_permissions`); or
            self-enrollment is off (`self_enrollment_disabled`) — the message
            names WHICH switch, since one is a tenant setting an admin controls
            and the other is a property of the course or path.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            Prerequisites are not met (`prerequisites_not_met`, courses only —
            `error.details.missing_prerequisites` lists the blocking courses), or
            the enrollment could not be written (`enrollment_failed`).
  "/training/courses/{course_id}/enrollment":
    delete:
      tags:
      - Training
      summary: Leave a self-enrolled course
      description: |
        **Requires the `write:training` scope.** No request body.

        The learner's OWN withdrawal — the undo of `POST .../enroll`, and the
        native twin of the web's `DELETE learner/courses/{id}/withdraw` and
        `DELETE learner/learning_paths/{id}/withdraw`. ONE operation, two
        routes — `/training/courses/{course_id}/enrollment` and
        `/training/learning_paths/{learning_path_id}/enrollment` — resolved
        from whichever id the route carried, exactly as the enroll pair is.

        **DELETE on the ENROLLMENT sub-resource, not on the course** (the same
        shape as `DELETE /training/sessions/{id}/registration`): the course is
        not being removed, the caller's enrollment in it is. Only the caller's
        own CURRENT enrollment is ever touched — there is no way to name
        somebody else's.

        **Deliberately narrow, because Training is the compliance system of
        record.** The rule is `Training::EnrollmentCancellationService
        .withdrawal_refusal`, the same one the web's withdraw actions and their
        sidebar buttons read, so mobile and web cannot disagree on who may
        leave what. It refuses, with `403 withdrawal_refused` and a
        learner-facing message naming why:

        * anything ASSIGNED — by a person, a training assignment, an automation
          rule, or a covering learning path (`enrollment.assigned` on the
          course detail / `assigned` on the card is the hint to hide the
          control) — "only an administrator can remove it";
        * a completed enrollment, or one holding a certificate — permanent
          history;
        * any enrollment when the tenant has self-enrollment turned off.

        A course withdrawal CLEARS the learner's lesson completions and quiz
        attempts on it (a self-paced course) or RELEASES the seat with waitlist
        promotion (an instructor-led one); the cancelled row is kept for audit.
        Both are irreversible — confirm before calling. Leaving a PATH does not
        cancel the member courses already started from it: those stay in
        My Training (and, having come from the path, cannot be dropped
        individually either), and the `message` says so with the count.

        After a successful withdrawal the learner may enroll again through
        `POST .../enroll` — the cancelled attempt is retired, not left blocking.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        description: The course to leave. Digit-constrained.
        schema:
          type: integer
          example: 128
      responses:
        '200':
          description: Withdrawn. `message` is learner-facing copy already worded
            for the delivery mode / kept-courses case.
          content:
            application/json:
              schema:
                type: object
                required:
                - withdrawn
                - message
                - enrollment
                properties:
                  withdrawn:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: You've been removed from "Forklift Refresher". Any progress
                      you'd made on it has been cleared.
                  enrollment:
                    type: object
                    description: The cancelled row, so a client holding the My Training
                      card it came from can drop that card without a refetch.
                    required:
                    - id
                    - status
                    properties:
                      id:
                        type: integer
                        example: 4412
                      status:
                        type: string
                        enum:
                        - cancelled
                        example: cancelled
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `write:training` scope (`insufficient_permissions`); or the
            learner may not withdraw from THIS enrollment (`withdrawal_refused`)
            — it was assigned, is completed or certificated, or self-enrollment
            is off for the tenant. The message names which, in learner-facing
            copy identical to the web's.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            The caller holds no current enrollment in it (`not_enrolled`), or the
            cancellation could not be written (`withdrawal_failed`).
  "/training/learning_paths/{learning_path_id}/enrollment":
    delete:
      tags:
      - Training
      summary: Leave a self-enrolled learning path
      description: |
        **Requires the `write:training` scope.** No request body.

        The learner's OWN withdrawal — the undo of `POST .../enroll`, and the
        native twin of the web's `DELETE learner/courses/{id}/withdraw` and
        `DELETE learner/learning_paths/{id}/withdraw`. ONE operation, two
        routes — `/training/courses/{course_id}/enrollment` and
        `/training/learning_paths/{learning_path_id}/enrollment` — resolved
        from whichever id the route carried, exactly as the enroll pair is.

        **DELETE on the ENROLLMENT sub-resource, not on the course** (the same
        shape as `DELETE /training/sessions/{id}/registration`): the course is
        not being removed, the caller's enrollment in it is. Only the caller's
        own CURRENT enrollment is ever touched — there is no way to name
        somebody else's.

        **Deliberately narrow, because Training is the compliance system of
        record.** The rule is `Training::EnrollmentCancellationService
        .withdrawal_refusal`, the same one the web's withdraw actions and their
        sidebar buttons read, so mobile and web cannot disagree on who may
        leave what. It refuses, with `403 withdrawal_refused` and a
        learner-facing message naming why:

        * anything ASSIGNED — by a person, a training assignment, an automation
          rule, or a covering learning path (`enrollment.assigned` on the
          course detail / `assigned` on the card is the hint to hide the
          control) — "only an administrator can remove it";
        * a completed enrollment, or one holding a certificate — permanent
          history;
        * any enrollment when the tenant has self-enrollment turned off.

        A course withdrawal CLEARS the learner's lesson completions and quiz
        attempts on it (a self-paced course) or RELEASES the seat with waitlist
        promotion (an instructor-led one); the cancelled row is kept for audit.
        Both are irreversible — confirm before calling. Leaving a PATH does not
        cancel the member courses already started from it: those stay in
        My Training (and, having come from the path, cannot be dropped
        individually either), and the `message` says so with the count.

        After a successful withdrawal the learner may enroll again through
        `POST .../enroll` — the cancelled attempt is retired, not left blocking.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        description: The learning path to leave. Digit-constrained.
        schema:
          type: integer
          example: 6
      responses:
        '200':
          description: Withdrawn. `message` is learner-facing copy already worded
            for the delivery mode / kept-courses case.
          content:
            application/json:
              schema:
                type: object
                required:
                - withdrawn
                - message
                - enrollment
                properties:
                  withdrawn:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: You've been removed from "Forklift Refresher". Any progress
                      you'd made on it has been cleared.
                  enrollment:
                    type: object
                    description: The cancelled row, so a client holding the My Training
                      card it came from can drop that card without a refetch.
                    required:
                    - id
                    - status
                    properties:
                      id:
                        type: integer
                        example: 4412
                      status:
                        type: string
                        enum:
                        - cancelled
                        example: cancelled
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `write:training` scope (`insufficient_permissions`); or the
            learner may not withdraw from THIS enrollment (`withdrawal_refused`)
            — it was assigned, is completed or certificated, or self-enrollment
            is off for the tenant. The message names which, in learner-facing
            copy identical to the web's.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            The caller holds no current enrollment in it (`not_enrolled`), or the
            cancellation could not be written (`withdrawal_failed`).
  "/training/catalog":
    get:
      tags:
      - Training
      summary: Course + learning-path catalog
      description: |
        The Catalog tab — browses published COURSES and LEARNING PATHS together
        (courses first, then paths), reconciled to the mobile prototype's controls:
        a `type` segmented tab bar (with per-type `counts`), a `category` filter, a
        `duration` bucket, and `sort`. Paginated.

        Cards are enrollment-aware (the prototype's Continue / Enroll / Register /
        Start-path CTAs): each carries `enrollment_status` (null when not enrolled)
        and a derived `cta_action`. A `type: course` card carries `lessons_count`
        (or `sessions_count` for instructor-led) / `rating` / `free` / `price`; a
        `type: path` card carries `steps_count` / `courses_count`. `counts` badges
        the type tabs; `filters` echoes the applied values.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: |-
          Free-text search over TITLE and DESCRIPTION, for courses AND paths, matched case-insensitively as a substring. Runs the models' own `search` scopes — the SAME predicate the web learner catalog and the `/m/` catalog run — so one term cannot return three different result sets across surfaces.

          NO RANKING, deliberately: results come back in the requested `sort` order, not by relevance, exactly as the web behaves. `sort=relevant` is the catalog's default order, not a relevance score.

          Blank or missing is a NO-OP, not an error — the full catalog comes back with a 200 — so a client may call this on every keystroke. A crafted `?q[]=x` is ignored the same way rather than searching for the literal text `["x"]`.

          COMPOSES WITH EVERY OTHER FILTER (AND), and it narrows `counts` — see there.
        schema:
          type: string
          example: safety
      - name: type
        in: query
        description: Narrow to one delivery type. Blank or unrecognised = all (and
          `filters.type` reports null, never an unapplied value). Segmented tabs;
          see `counts`.
        schema:
          type: string
          enum:
          - self_paced
          - instructor_led
          - learning_path
          example: self_paced
      - name: category
        in: query
        description: A category NAME (case-insensitive; courses match the free-text
          column, paths match their TrainingCategory name). See /training/catalog/categories.
        schema:
          type: string
          example: Safety
      - name: duration
        in: query
        description: Duration bucket over the displayed total (Under 30 min / 30–60
          min / Over 1 hour). Items with an unknown/zero duration match no bucket.
        schema:
          type: string
          enum:
          - under_30
          - '30_60'
          - over_60
          example: under_30
      - name: sort
        in: query
        description: relevant (default; catalog order) · newest (published/created
          desc) · shortest (duration asc) · due_date (caller's enrollment due date
          asc, unenrolled last).
        schema:
          type: string
          enum:
          - relevant
          - newest
          - shortest
          - due_date
          default: relevant
          example: relevant
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Catalog retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - filters
                - counts
                - meta
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: A course or path browse tile. `type` selects which
                        fields are present.
                      required:
                      - id
                      - type
                      - title
                      - delivery_label
                      - enrollment_status
                      - enrolled
                      - cta_action
                      properties:
                        id:
                          type: integer
                          example: 320
                        type:
                          type: string
                          enum:
                          - course
                          - path
                          example: course
                        title:
                          type: string
                          example: Workplace Safety Fundamentals
                        slug:
                          type: string
                          nullable: true
                          example: workplace-safety-fundamentals
                        description:
                          type: string
                          nullable: true
                          description: Plain-text snippet (HTML stripped, truncated
                            to 120 chars — the same rule and length as the web cards).
                          example: Core safety practices for the floor.
                        delivery_label:
                          type: string
                          description: Self-paced / Instructor-Led / Learning Path.
                          enum:
                          - Self-paced
                          - Instructor-Led
                          - Learning Path
                          example: Self-paced
                        delivery_icon:
                          type: string
                          description: Font Awesome glyph for the delivery type —
                            the web banner's type fallback (fa-circle-play / fa-chalkboard-user
                            / fa-route).
                          example: fa-circle-play
                        category:
                          type: object
                          nullable: true
                          description: The subject's TrainingCategory - name, hex
                            color and Font Awesome glyph - for a COURSE and a path
                            alike. Falls back to a legacy free-text course category
                            (name only, null color/icon) when no category record is
                            linked, and is null when the subject has neither.
                          properties:
                            name:
                              type: string
                              example: Safety
                            color:
                              type: string
                              nullable: true
                              example: "#28a745"
                            icon:
                              type: string
                              nullable: true
                              example: fa-shield-alt
                        hero_image_url:
                          type: string
                          nullable: true
                          description: Absolute URL of a resized hero image, or null.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                        formatted_duration:
                          type: string
                          nullable: true
                          example: 1h 30m
                        enrollment_status:
                          type: string
                          nullable: true
                          enum:
                          - enrolled
                          - in_progress
                          - completed
                          - cancelled
                          description: The caller's raw enrollment status, or null
                            when not enrolled. `cancelled` falls through to an enrol-style
                            cta_action, exactly as if unenrolled.
                          example:
                        enrolled:
                          type: boolean
                          example: false
                        cta_action:
                          type: string
                          enum:
                          - enroll
                          - register
                          - start_path
                          - continue
                          - buy
                          - view
                          - locked
                          description: 'Derived button action (client maps to a label).
                            `locked` means the caller may not act on this item from
                            the catalog — render NO CTA (the same value `TrainingDetailCta.action`
                            carries): a learning path or course with self-enrollment
                            off, an instructor-led course not offering registration,
                            or a priced course on a tenant without commerce. The set
                            is exactly what `CatalogCardSerializer#cta_action` returns;
                            a closed enum in a generated client fails the whole response
                            on an unknown value, so every value the server can send
                            is listed here.'
                          example: enroll
                        lessons_count:
                          type: integer
                          description: Course cards only.
                          example: 6
                        sessions_count:
                          type: integer
                          description: Instructor-led course cards only — BOOKABLE
                            sessions (scheduled and still upcoming), the same number
                            the course detail reports as sessions_available. Cancelled
                            and past dates are excluded.
                          example: 7
                        rating:
                          type: object
                          description: Course cards only.
                          properties:
                            average:
                              type: number
                              nullable: true
                              example: 4.5
                            count:
                              type: integer
                              example: 12
                        free:
                          type: boolean
                          description: Course cards only.
                          example: true
                        price:
                          type: string
                          description: 'Course cards only — the formatted cost, always
                            present: the price when the course is paid, "Free" when
                            it is not. Branch on `free` for logic; `price` is display
                            text either way.'
                          example: "$49.00"
                        steps_count:
                          type: integer
                          description: Path cards only.
                          example: 3
                        courses_count:
                          type: integer
                          description: Path cards only.
                          example: 8
                  filters:
                    type: object
                    description: The applied filter values (null when not set).
                    properties:
                      q:
                        type: string
                        nullable: true
                        description: The applied search term
                        ? or null. Echoed so a client can badge it and tell "filtered
                          and empty" apart from "my term was ignored" — a crafted
                          non-scalar `q` comes back null.
                        :
                        example:
                      type:
                        type: string
                        nullable: true
                        example:
                      category:
                        type: string
                        nullable: true
                        example:
                      duration:
                        type: string
                        nullable: true
                        enum:
                        - under_30
                        - '30_60'
                        - over_60
                        example:
                      sort:
                        type: string
                        enum:
                        - relevant
                        - newest
                        - shortest
                        - due_date
                        example: relevant
                  counts:
                    type: object
                    description: |-
                      Per-type tab counts over the set narrowed by `q` AND `category`, but NOT by `type` — so tapping a type tab does not change them, while searching or switching category does. Overlap-free: each item is exactly one type.

                      EACH COUNT IS WHAT TAPPING THAT TAB RETURNS. That is why `q` is inside the count and `type` is outside it: `q` and `category` narrow what the learner is looking at, whereas `type` IS the tab that was tapped, and counting after it would leave every tab showing its own count and zero for its siblings. Concretely, with `?q=safety` on a 45-item catalog the counts come back all=5 / self_paced=3 / instructor_led=0 / learning_path=2 — and `?q=safety&type=instructor_led` really does return an empty list, which is what the 0 is telling the client in advance.
                    required:
                    - all
                    - self_paced
                    - instructor_led
                    - learning_path
                    properties:
                      all:
                        type: integer
                        example: 53
                      self_paced:
                        type: integer
                        example: 41
                      instructor_led:
                        type: integer
                        example: 5
                      learning_path:
                        type: integer
                        example: 7
                  meta:
                    type: object
                    description: Pagination over the merged (courses + paths) result
                      (build_pagination_meta).
                    properties:
                      total_count:
                        type: integer
                        example: 53
                      total_pages:
                        type: integer
                        example: 3
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: true
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
  "/training/catalog/categories":
    get:
      tags:
      - Training
      summary: Catalog category filter options
      description: |
        The category filter options for the Catalog tab — the case-insensitive
        union of course (free-text) and path (TrainingCategory) categories, each
        with a `count` of matching published items and, when a TrainingCategory
        backs the name, its `color` / `icon`. Sorted alphabetically.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - categories
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      required:
                      - name
                      - value
                      - count
                      properties:
                        name:
                          type: string
                          description: Display name.
                          example: Safety
                        value:
                          type: string
                          description: Pass as the catalog `category` param.
                          example: Safety
                        count:
                          type: integer
                          description: Published courses + paths in this category.
                          example: 7
                        color:
                          type: string
                          nullable: true
                          example: "#28a745"
                        icon:
                          type: string
                          nullable: true
                          example: fa-shield-alt
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
  "/training/my_records/certificates":
    get:
      tags:
      - Training
      summary: My Records — certificates
      description: |
        The learner's certificates, the native mirror of the web My Certificates
        screen (`Apps::Training::Learner::CertificatesController#index`), newest
        issued first and paginated.

        A **Certificate of Completion never expires** — it is permanent proof — so
        `summary.held` is simply the total, and a certificate's `status` reads
        `proof_only` unless it is linked to a certification (via the skill the
        course awarded), which is where any expiry clock lives. The status wording
        comes from `Training::Display`, the same source the web validity chip
        reads, so the chip and the native badge can never disagree.

        SCOPE: this serves `TrainingCertificate` rows only. The web screen also
        merges externally-recorded credentials (`EmployeeSkill`, source
        `external`); every row here carries `source: "training"` so adding those
        later is additive rather than a breaking change.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Certificates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - certificates
                - summary
                - meta
                properties:
                  certificates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - source
                      - certificate_number
                      - title
                      - status
                      properties:
                        id:
                          type: integer
                          example: 64
                        source:
                          type: string
                          enum:
                          - training
                          description: Always "training" today; the discriminator
                            exists for future external credentials.
                          example: training
                        certificate_number:
                          type: string
                          description: Human-quotable id, format CERT-YYYYMMDD-XXXXXX.
                          example: CERT-20260803-BPAWWC
                        title:
                          type: string
                          description: The course or path the certificate was earned
                            for.
                          example: Benefits & Policies
                        issuer:
                          type: string
                          nullable: true
                          description: The issuing business.
                          example: OfficeChat
                        issued_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-08-03T16:38:11Z'
                        issued_at_label:
                          type: string
                          nullable: true
                          description: Pre-formatted issue date, matching the web.
                          example: August 03
                          2026:
                        credits:
                          type: number
                          nullable: true
                          description: CE credits (frozen snapshot); null when zero
                            so a client renders an em dash.
                          example: 1.5
                        status:
                          type: object
                          description: Validity chip. `proof_only` = a completion
                            certificate with no linked certification, which never
                            expires.
                          required:
                          - key
                          - label
                          - color
                          properties:
                            key:
                              type: string
                              enum:
                              - proof_only
                              - valid
                              - expiring
                              - expired
                              example: proof_only
                            label:
                              type: string
                              description: Display string, including the day count
                                when expiring.
                              example: Proof only
                            color:
                              type: string
                              enum:
                              - secondary
                              - success
                              - warning
                              - danger
                              example: secondary
                            days_until_expiration:
                              type: integer
                              nullable: true
                              description: Present only when the key is `expiring`.
                              example:
                        expires_at:
                          type: string
                          format: date
                          nullable: true
                          description: From the LINKED certification; null for proof-only
                            certificates.
                          example:
                        certification_name:
                          type: string
                          nullable: true
                          description: Name of the linked certification (the skill's
                            name, else the issuing authority).
                          example:
                        subject:
                          type: object
                          nullable: true
                          description: The course or path. Resolved SYSTEM-safely,
                            so a tenant-hidden course still names the row.
                          properties:
                            id:
                              type: integer
                              example: 60
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            title:
                              type: string
                              example: Benefits & Policies
                            delivery_label:
                              type: string
                              enum:
                              - Self-paced
                              - Instructor-Led
                              - Learning Path
                              example: Self-paced
                            category:
                              type: object
                              nullable: true
                              properties:
                                name:
                                  type: string
                                  example: onboarding
                                color:
                                  type: string
                                  nullable: true
                                  example:
                                icon:
                                  type: string
                                  nullable: true
                                  example:
                        enrollment_id:
                          type: integer
                          nullable: true
                          description: The enrollment this certificate came from;
                            matches a transcript entry's id (the detail screen's See
                            this on my transcript link).
                          example: 22750
                        course_version:
                          type: object
                          nullable: true
                          description: The pinned course version, rendered on the
                            detail screen as "v4 · Jul 2026". Courses only — a path
                            pins no version.
                          properties:
                            label:
                              type: string
                              example: v4
                            released_at:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2026-07-01T00:00:00Z'
                        verification_url:
                          type: string
                          nullable: true
                          description: Public, unauthenticated verification page for
                            this certificate number.
                          example: https://app.workforce.mangoapps.com/verify/certificate/CERT-20260803-BPAWWC
                        download_url:
                          type: string
                          nullable: true
                          description: Absolute URL of the certificate PDF; null when
                            the PDF has not been generated yet.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/...
                        thumbnail_url:
                          type: string
                          nullable: true
                          description: Preview image of the PDF's first page (what
                            the web card shows).
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/representations/...
                  summary:
                    type: object
                    description: The header tiles, computed over the learner's WHOLE
                      set (never the current page, which would show different numbers
                      on page 2).
                    required:
                    - held
                    - active
                    - expiring_soon
                    - expired
                    - completions
                    properties:
                      held:
                        type: integer
                        description: Total certificates held (completion proof never
                          expires, so this is the whole count).
                        example: 3
                      active:
                        type: integer
                        description: Certificates whose linked certification has not
                          expired.
                        example: 3
                      expiring_soon:
                        type: integer
                        description: Linked certifications expiring within 30 days.
                        example: 0
                      expired:
                        type: integer
                        description: Linked certifications already expired.
                        example: 0
                      completions:
                        type: integer
                        description: The sibling Transcript tab's badge, so either
                          endpoint can paint the whole tab bar.
                        example: 8
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 3
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
  "/training/my_records/certificates/{id}":
    get:
      tags:
      - Training
      summary: My Records — one certificate
      description: |
        A single certificate — the Certificate Detail screen. Resolved through the
        same learner-scoped loader as the list, so another learner's (or another
        tenant's) certificate returns 404 rather than leaking.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 64
      responses:
        '200':
          description: Certificate retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - certificate
                properties:
                  certificate:
                    type: object
                    required:
                    - id
                    - source
                    - certificate_number
                    - title
                    - status
                    properties:
                      id:
                        type: integer
                        example: 64
                      source:
                        type: string
                        enum:
                        - training
                        description: Always "training" today; the discriminator exists
                          for future external credentials.
                        example: training
                      certificate_number:
                        type: string
                        description: Human-quotable id, format CERT-YYYYMMDD-XXXXXX.
                        example: CERT-20260803-BPAWWC
                      title:
                        type: string
                        description: The course or path the certificate was earned
                          for.
                        example: Benefits & Policies
                      issuer:
                        type: string
                        nullable: true
                        description: The issuing business.
                        example: OfficeChat
                      issued_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-08-03T16:38:11Z'
                      issued_at_label:
                        type: string
                        nullable: true
                        description: Pre-formatted issue date, matching the web.
                        example: August 03
                        2026:
                      credits:
                        type: number
                        nullable: true
                        description: CE credits (frozen snapshot); null when zero
                          so a client renders an em dash.
                        example: 1.5
                      status:
                        type: object
                        description: Validity chip. `proof_only` = a completion certificate
                          with no linked certification, which never expires.
                        required:
                        - key
                        - label
                        - color
                        properties:
                          key:
                            type: string
                            enum:
                            - proof_only
                            - valid
                            - expiring
                            - expired
                            example: proof_only
                          label:
                            type: string
                            description: Display string, including the day count when
                              expiring.
                            example: Proof only
                          color:
                            type: string
                            enum:
                            - secondary
                            - success
                            - warning
                            - danger
                            example: secondary
                          days_until_expiration:
                            type: integer
                            nullable: true
                            description: Present only when the key is `expiring`.
                            example:
                      expires_at:
                        type: string
                        format: date
                        nullable: true
                        description: From the LINKED certification; null for proof-only
                          certificates.
                        example:
                      certification_name:
                        type: string
                        nullable: true
                        description: Name of the linked certification (the skill's
                          name, else the issuing authority).
                        example:
                      subject:
                        type: object
                        nullable: true
                        description: The course or path. Resolved SYSTEM-safely, so
                          a tenant-hidden course still names the row.
                        properties:
                          id:
                            type: integer
                            example: 60
                          type:
                            type: string
                            enum:
                            - course
                            - path
                            example: course
                          title:
                            type: string
                            example: Benefits & Policies
                          delivery_label:
                            type: string
                            enum:
                            - Self-paced
                            - Instructor-Led
                            - Learning Path
                            example: Self-paced
                          category:
                            type: object
                            nullable: true
                            properties:
                              name:
                                type: string
                                example: onboarding
                              color:
                                type: string
                                nullable: true
                                example:
                              icon:
                                type: string
                                nullable: true
                                example:
                      enrollment_id:
                        type: integer
                        nullable: true
                        description: The enrollment this certificate came from; matches
                          a transcript entry's id (the detail screen's See this on
                          my transcript link).
                        example: 22750
                      course_version:
                        type: object
                        nullable: true
                        description: The pinned course version, rendered on the detail
                          screen as "v4 · Jul 2026". Courses only — a path pins no
                          version.
                        properties:
                          label:
                            type: string
                            example: v4
                          released_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-07-01T00:00:00Z'
                      verification_url:
                        type: string
                        nullable: true
                        description: Public, unauthenticated verification page for
                          this certificate number.
                        example: https://app.workforce.mangoapps.com/verify/certificate/CERT-20260803-BPAWWC
                      download_url:
                        type: string
                        nullable: true
                        description: Absolute URL of the certificate PDF; null when
                          the PDF has not been generated yet.
                        example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/...
                      thumbnail_url:
                        type: string
                        nullable: true
                        description: Preview image of the PDF's first page (what the
                          web card shows).
                        example: https://acme.workforce.mangoapps.com/rails/active_storage/representations/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such certificate for this learner (error code `not_found`).
  "/training/my_records/transcript":
    get:
      tags:
      - Training
      summary: My Records — transcript
      description: |
        The learner's official record of completed training — courses AND learning
        paths merged, newest completion first — mirroring the web transcript page
        (`Apps::Training::Learner::TranscriptController#index`) and the transcript
        PDF, all three reading one shared loader.

        Rows paginate, but `summary` stays on the FULL filtered set: `total_ceu`
        therefore tracks `?range` and always equals the sum of every row in the
        current view rather than just the page — exactly as the web tiles behave.
        Each row's `credits` is that item's own frozen CE-credit snapshot.

        Path rows carry no `version_label` or `score` (a path pins no version and
        runs no quiz); the web renders an em dash for both.
      security:
      - BearerAuth: []
      parameters:
      - name: range
        in: query
        description: Limit to the current calendar year. Unrecognised values are treated
          as `all`. Ignored when `year` is given.
        schema:
          type: string
          enum:
          - all
          - year
          default: all
          example: all
      - name: year
        in: query
        description: A specific calendar year (the mobile period picker). Wins over
          `range`; see `available_years` for the options that have records.
        schema:
          type: integer
          example: 2026
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Transcript retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - entries
                - learner
                - range
                - summary
                - meta
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - type
                      - completed_at
                      properties:
                        id:
                          type: integer
                          description: The enrollment id.
                          example: 22750
                        type:
                          type: string
                          enum:
                          - course
                          - path
                          example: course
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-08-03T16:38:11Z'
                        year:
                          type: integer
                          nullable: true
                          description: Completion year — the transcript groups rows
                            under year headings.
                          example: 2026
                        version_label:
                          type: string
                          nullable: true
                          description: Course rows only — the pinned course version.
                          example: v1
                        score:
                          type: number
                          nullable: true
                          description: Course rows only — best quiz score (percent);
                            null when no scored quiz.
                          example:
                        credits:
                          type: number
                          nullable: true
                          description: CE credits for this item; null when zero.
                          example: 1.5
                        certificate_id:
                          type: integer
                          nullable: true
                          description: Fetch via /my_records/certificates/:id; null
                            when the completion issued no certificate.
                          example: 64
                        subject:
                          type: object
                          nullable: true
                          description: Null when the course is gone or hidden — the
                            web prints "Course unavailable" but keeps the completion
                            on the record.
                          properties:
                            id:
                              type: integer
                              example: 60
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            title:
                              type: string
                              example: Benefits & Policies
                            delivery_label:
                              type: string
                              enum:
                              - Self-paced
                              - Instructor-Led
                              - Learning Path
                              example: Self-paced
                            category:
                              type: object
                              nullable: true
                              properties:
                                name:
                                  type: string
                                  example: onboarding
                                color:
                                  type: string
                                  nullable: true
                                  example:
                                icon:
                                  type: string
                                  nullable: true
                                  example:
                  learner:
                    type: object
                    description: Whose official record this is — the transcript header
                      (avatar initials, name, and the "role · location" line).
                    properties:
                      name:
                        type: string
                        example: Marcus Delgado
                      initials:
                        type: string
                        nullable: true
                        example: MD
                      job_title:
                        type: string
                        nullable: true
                        description: This tenant's job title for the learner.
                        example: Parts Counter Associate
                      location:
                        type: string
                        nullable: true
                        example: 'Store #142'
                  range:
                    type: string
                    enum:
                    - all
                    - year
                    description: The all-time / this-year toggle actually applied.
                      Ignored when `year` is given.
                    example: all
                  year:
                    type: integer
                    nullable: true
                    description: The specific period applied via ?year=, or null when
                      unfiltered.
                    example:
                  available_years:
                    type: array
                    description: Years the learner actually completed something in,
                      newest first — the period picker's options.
                    items:
                      type: integer
                      example: 2026
                  summary:
                    type: object
                    description: The web page's three tiles, over the full filtered
                      set.
                    required:
                    - completions
                    - total_ceu
                    - total_ceu_all_time
                    - certificates_held
                    properties:
                      completions:
                        type: integer
                        description: Rows in the current view (tracks the period filter).
                        example: 8
                      total_ceu:
                        type: number
                        description: Sum of every row in the current view (tracks
                          the period filter), rounded to 1dp.
                        example: 3.5
                      total_ceu_all_time:
                        type: number
                        description: Lifetime CEU total, never period-filtered — the
                          figure shown above the period picker.
                        example: 18.5
                      certificates_held:
                        type: integer
                        description: All-time certificate count (not range-filtered).
                        example: 3
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 8
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
  "/training/courses/{id}":
    get:
      tags:
      - Training
      summary: Course detail (self-paced or instructor-led)
      description: |
        One course's detail screen, the native mirror of the web learner course page
        (`Apps::Training::Learner::CoursesController#show`). SELF-PACED and
        INSTRUCTOR-LED share this one payload — `delivery_mode` tells the client
        which variant to render, exactly as the web branches on `instructor_led?`:

        * self-paced — a progress block, a `lessons` outline, and a resume CTA;
        * instructor-led — no progress block, a `sessions` list with capacity and
          booking CTAs, the instructors, and an "Attendance: Instructor-marked" info
          row (a constant of the ILT flow, since the instructor marks the roster).

        `tabs` is the web's tab allowlist verbatim: an ILT course shows
        `about / sessions / qa`, an enrolled self-paced course `about / lessons / qa`,
        and a self-paced course you are only browsing shows `about` alone.

        **Two deliberate differences from the web page, both because this is a GET:**

        * **No writes.** The web `#show` auto-enrols the learner when the course sits
          inside an active learning path and calls `start!`, moving them from
          enrolled to in_progress just for opening the page. A read endpoint does
          neither — the client uses the actions that own those transitions.
        * **Prerequisites are reported, not withheld.** The web renders a
          "prerequisites required" page INSTEAD of the course; this returns the
          course with `prerequisites: { met: false, missing: [...] }` so a client can
          render the same blocked screen without a second request.

        `info_rows` and `additional_info` are ORDERED label/value pairs, not fixed
        keys: the "Course Info" card and the custom-field block are both
        tenant-configurable (`additional_info` is every custom field flagged
        show-on-info-page that has a value), so a client renders what it is given
        rather than hardcoding a field list. `info_rows` arrives in the web Course
        Info card's own order — Duration, Lessons, Difficulty, Credits,
        Certificate, Self-enroll — with the two rows the web card has no
        equivalent for (Due, Version) after them.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 320
      responses:
        '200':
          description: Course retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - course
                properties:
                  course:
                    type: object
                    required:
                    - id
                    - type
                    - title
                    - delivery_mode
                    - delivery_label
                    - tabs
                    - cta
                    properties:
                      id:
                        type: integer
                        example: 320
                      type:
                        type: string
                        enum:
                        - course
                        example: course
                      title:
                        type: string
                        example: Workplace Safety Fundamentals
                      slug:
                        type: string
                        nullable: true
                        example: workplace-safety-fundamentals
                      delivery_mode:
                        type: string
                        enum:
                        - self_paced
                        - instructor_led
                        example: self_paced
                      delivery_label:
                        type: string
                        enum:
                        - Self-paced
                        - Instructor-Led
                        example: Self-paced
                      delivery_icon:
                        type: string
                        example: fa-circle-play
                      category:
                        type: object
                        nullable: true
                        description: From the course's TrainingCategory record — the
                          same colour and glyph the web renders. Null only for a course
                          with no category linked.
                        properties:
                          name:
                            type: string
                            example: Health & Safety
                          color:
                            type: string
                            nullable: true
                            example: "#28a745"
                          icon:
                            type: string
                            nullable: true
                            example: fa-shield-alt
                      categories:
                        type: array
                        description: The free-text category split on commas — the
                          hero renders one pill each.
                        items:
                          type: string
                          example: Safety
                      hero_image_url:
                        type: string
                        nullable: true
                        example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                      description:
                        type: string
                        nullable: true
                        description: HTML.
                        example: "<p>The safety baseline every team member needs.</p>"
                      learning_objectives:
                        type: string
                        nullable: true
                        description: HTML — the What you'll be able to do block.
                        example: "<ul><li>Identify hazards</li></ul>"
                      intro_video:
                        type: object
                        nullable: true
                        description: 'Present only when the course has an intro video
                          (embed URL or an attached file). The two are mutually exclusive
                          in practice and reported in SEPARATE fields: `embed_url`
                          goes in an iframe/webview, `file_url` is media bytes for
                          a native player.'
                        properties:
                          embed_url:
                            type: string
                            nullable: true
                            example: https://www.youtube.com/embed/abc123
                          provider:
                            type: string
                            nullable: true
                            example: youtube
                          attached:
                            type: boolean
                            example: false
                          file_url:
                            type: string
                            nullable: true
                            description: Absolute, EXPIRING (1 hour) URL of the uploaded
                              intro video, served inline. Non-null only when `attached`
                              is true; re-fetch the course rather than caching it.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/intro.mp4?disposition=inline
                          content_type:
                            type: string
                            nullable: true
                            description: MIME type of the uploaded file, for picking
                              a player. Non-null only when `attached` is true.
                            example: video/mp4
                      difficulty_level:
                        type: string
                        nullable: true
                        enum:
                        - beginner
                        - intermediate
                        - advanced
                        example: beginner
                      formatted_duration:
                        type: string
                        nullable: true
                        description: Duration of the lesson set in `lessons[]` — ONE
                          version, the caller's pinned version when enrolled and the
                          current published one when browsing. NOT the sum across
                          every version the course has ever had.
                        example: 1h 05m
                      duration_minutes:
                        type: integer
                        nullable: true
                        description: The same figure as `formatted_duration`, in minutes.
                        example: 65
                      lessons_count:
                        type: integer
                        example: 6
                      ce_credits:
                        type: number
                        nullable: true
                        description: null when zero.
                        example: 1.5
                      requires_certificate:
                        type: boolean
                        example: true
                      certificate_validity_days:
                        type: integer
                        nullable: true
                        description: Raw validity window. The screen's Renewal Cycle
                          line comes from a custom field, not from this.
                        example: 365
                      version:
                        type: object
                        nullable: true
                        description: The current published version.
                        properties:
                          label:
                            type: string
                            example: v4
                          released_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-07-01T00:00:00Z'
                      instructors:
                        type: array
                        description: Instructor-led only — the distinct instructors
                          across the UPCOMING sessions (so a course whose sessions
                          have all finished reports `[]`, matching `sessions_available`).
                          Carries the same person shape as the reviews and Q&A payloads,
                          so one avatar component renders all three.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 44
                            name:
                              type: string
                              example: Maria Lopez
                            initials:
                              type: string
                              nullable: true
                              description: For a client that would rather draw its
                                own avatar tile than load one.
                              example: ML
                            job_title:
                              type: string
                              nullable: true
                              example: Regional Safety Trainer
                            avatar_url:
                              type: string
                              description: Absolute. Never null for a live user —
                                falls back to a generated initials tile when no photo
                                is uploaded, so it is safe to render unconditionally.
                              example: https://…/avatar.png
                      rating:
                        type: object
                        description: Approved reviews only.
                        properties:
                          average:
                            type: number
                            nullable: true
                            example: 4.6
                          count:
                            type: integer
                            example: 128
                          distribution:
                            type: object
                            description: Star -> count, for the 5..1 histogram.
                            additionalProperties:
                              type: integer
                            example:
                              '5': 88
                              '4': 27
                              '3': 8
                              '2': 3
                              '1': 2
                          reviews:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TrainingReviewRow"
                      free:
                        type: boolean
                        example: true
                      price:
                        type: string
                        description: Formatted cost, always present — the price when
                          paid, "Free" when not. Branch on `free` for logic.
                        example: Free
                      allow_self_enrollment:
                        type: boolean
                        example: true
                      tabs:
                        type: array
                        description: 'The web tab allowlist: ILT -> about/sessions/qa;
                          enrolled self-paced -> about/lessons/qa; browsing self-paced
                          -> about.'
                        items:
                          type: string
                          enum:
                          - about
                          - lessons
                          - sessions
                          - qa
                        example:
                        - about
                        - lessons
                        - qa
                      info_rows:
                        "$ref": "#/components/schemas/TrainingInfoRows"
                      additional_info:
                        type: array
                        description: Custom fields flagged show-on-info-page that
                          hold a value, in the admin's order. Drives the "Additional
                          information" block (e.g. Regulatory Body, Renewal Cycle,
                          Course Code).
                        items:
                          type: object
                          properties:
                            label:
                              type: string
                              example: Regulatory Body
                            value:
                              type: string
                              example: OSHA 1910
                            field_type:
                              type: string
                              example: text
                      enrollment:
                        type: object
                        nullable: true
                        description: The caller's enrollment, or null when only browsing.
                        properties:
                          id:
                            type: integer
                            example: 4412
                          status:
                            type: string
                            enum:
                            - enrolled
                            - in_progress
                            - completed
                            - cancelled
                            example: in_progress
                          status_key:
                            type: string
                            enum:
                            - not_started
                            - in_progress
                            - completed
                            - registered
                            example: in_progress
                          status_label:
                            type: string
                            example: In Progress
                          progress_percentage:
                            type: integer
                            example: 50
                          completed_lessons_count:
                            type: integer
                            example: 3
                          total_lessons:
                            type: integer
                            example: 6
                          formatted_time_left:
                            type: string
                            nullable: true
                            example: 45m
                          formatted_time_spent:
                            type: string
                            nullable: true
                            example: 20m
                          due_date:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-08-15T00:00:00Z'
                          overdue:
                            type: boolean
                            example: false
                          days_overdue:
                            type: integer
                            nullable: true
                            example:
                          assigned:
                            type: boolean
                            description: The enrollment was given to the learner rather
                              than self-chosen.
                            example: true
                          completed_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          certificate_id:
                            type: integer
                            nullable: true
                            example:
                      course_paths:
                        type: array
                        description: The learning paths this course counts toward
                          FOR THE CALLER — the web page's "Assigned via {path} (+N
                          more)" pill, as an array so a course in two paths is represented
                          honestly. Strictly the caller's OWN active (enrolled / in_progress)
                          path enrollments that include this course; a path the caller
                          is not on, or has completed or left, never appears, and
                          it is never "every path that lists the course". `[]` for
                          a course reached directly. `id` is the PATH's, so each entry
                          opens through `GET /training/learning_paths/{id}`.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          properties:
                            id:
                              type: integer
                              example: 88
                            title:
                              type: string
                              example: New Hire Safety Onboarding
                      prerequisites:
                        type: object
                        description: The web BLOCKS the page when required prerequisites
                          are unmet; this reports the state instead.
                        required:
                        - met
                        - missing
                        properties:
                          met:
                            type: boolean
                            example: true
                          missing:
                            type: array
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 88
                                title:
                                  type: string
                                  example: Safety Orientation
                      lessons:
                        type: array
                        description: 'The outline the caller may see: an enrolled
                          learner gets the version they are PINNED to, a browser the
                          current published version. The same rows the Lessons tab
                          endpoint returns.'
                        items:
                          "$ref": "#/components/schemas/TrainingLessonRow"
                      sessions_available:
                        type: integer
                        nullable: true
                        description: Instructor-led only — count of upcoming scheduled
                          sessions.
                        example: 7
                      sessions:
                        type: array
                        nullable: true
                        description: Instructor-led only.
                        items:
                          "$ref": "#/components/schemas/TrainingSessionRow"
                      my_registration:
                        "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                      qa:
                        type: object
                        properties:
                          questions_count:
                            type: integer
                            example: 3
                          unanswered_count:
                            type: integer
                            example: 1
                      can_review:
                        type: boolean
                        description: Enrolled and has not already reviewed.
                        example: true
                      my_review_id:
                        type: integer
                        nullable: true
                        example:
                      cta:
                        "$ref": "#/components/schemas/TrainingDetailCta"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller (error code `not_found`).
  "/training/learning_paths/{id}":
    get:
      tags:
      - Training
      summary: Learning-path detail
      description: |
        One learning path's detail screen, the native mirror of the web learner path
        page (`Apps::Training::Learner::LearningPathsController#show`). Same shape as
        a course detail with `type: "path"`, plus the `steps` outline.

        **Progress is computed LIVE** from required work (`required_done` /
        `required_total` over the step weights) and never read from the enrollment's
        cached `progress_percentage`: a member course completed outside the path
        leaves that column stale, which is why the web mobile view recomputes it too.

        A step is `locked` only when the path is `sequential`, the caller is
        enrolled, and the step sits after the first incomplete one — the web's exact
        rule.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 3
      responses:
        '200':
          description: Learning path retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - learning_path
                properties:
                  learning_path:
                    type: object
                    required:
                    - id
                    - type
                    - title
                    - tabs
                    - cta
                    properties:
                      id:
                        type: integer
                        example: 3
                      type:
                        type: string
                        enum:
                        - path
                        example: path
                      title:
                        type: string
                        example: New Hire Safety Onboarding
                      slug:
                        type: string
                        nullable: true
                        example: new-hire-safety
                      delivery_mode:
                        type: string
                        enum:
                        - learning_path
                        example: learning_path
                      delivery_label:
                        type: string
                        enum:
                        - Learning Path
                        example: Learning Path
                      delivery_icon:
                        type: string
                        example: fa-route
                      category:
                        type: object
                        nullable: true
                        properties:
                          name:
                            type: string
                            example: Onboarding
                          color:
                            type: string
                            nullable: true
                            example: "#ffc107"
                          icon:
                            type: string
                            nullable: true
                            example: fa-door-open
                      hero_image_url:
                        type: string
                        nullable: true
                        example:
                      description:
                        type: string
                        nullable: true
                        example: "<p>The onboarding path every new floor team member
                          completes.</p>"
                      learning_objectives:
                        type: string
                        nullable: true
                        example:
                      intro_video:
                        type: object
                        nullable: true
                        description: Same shape as the course detail's — see `file_url`
                          there.
                        properties:
                          embed_url:
                            type: string
                            nullable: true
                            example:
                          provider:
                            type: string
                            nullable: true
                            example:
                          attached:
                            type: boolean
                            example: false
                          file_url:
                            type: string
                            nullable: true
                            description: Absolute
                            expiring (1 hour) URL of the uploaded intro video. Non-null only when `attached`.:
                            example:
                          content_type:
                            type: string
                            nullable: true
                            example:
                      difficulty_level:
                        type: string
                        nullable: true
                        enum:
                        - beginner
                        - intermediate
                        - advanced
                        example: beginner
                      formatted_duration:
                        type: string
                        nullable: true
                        example: 6h 20m
                      steps_count:
                        type: integer
                        example: 3
                      courses_count:
                        type: integer
                        example: 8
                      ce_credits:
                        type: number
                        nullable: true
                        example: 6.5
                      requires_certificate:
                        type: boolean
                        example: true
                      sequential:
                        type: boolean
                        description: Steps unlock in order.
                        example: true
                      rating:
                        type: object
                        properties:
                          average:
                            type: number
                            nullable: true
                            example: 4.4
                          count:
                            type: integer
                            example: 61
                          distribution:
                            type: object
                            additionalProperties:
                              type: integer
                            example:
                              '5': 34
                              '4': 18
                          reviews:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TrainingReviewRow"
                      allow_self_enrollment:
                        type: boolean
                        example: true
                      tabs:
                        type: array
                        items:
                          type: string
                          enum:
                          - about
                          - steps
                          - qa
                        example:
                        - about
                        - steps
                        - qa
                      info_rows:
                        "$ref": "#/components/schemas/TrainingInfoRows"
                      additional_info:
                        type: array
                        items:
                          type: object
                          properties:
                            label:
                              type: string
                              example: Path Code
                            value:
                              type: string
                              example: ONB-SAF-01
                            field_type:
                              type: string
                              example: text
                      enrollment:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 50
                          status:
                            type: string
                            enum:
                            - enrolled
                            - in_progress
                            - completed
                            - cancelled
                            example: in_progress
                          status_key:
                            type: string
                            enum:
                            - not_started
                            - in_progress
                            - completed
                            - registered
                            example: in_progress
                          status_label:
                            type: string
                            example: In Progress
                          progress_percentage:
                            type: integer
                            description: LIVE required-work percentage, not the cached
                              column.
                            example: 60
                          required_done:
                            type: integer
                            example: 4
                          required_total:
                            type: integer
                            example: 8
                          steps_complete:
                            type: integer
                            example: 1
                          steps_total:
                            type: integer
                            example: 3
                          due_date:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-08-15T00:00:00Z'
                          overdue:
                            type: boolean
                            example: false
                          days_overdue:
                            type: integer
                            nullable: true
                            example:
                          assigned:
                            type: boolean
                            example: true
                          completed_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          certificate_id:
                            type: integer
                            nullable: true
                            example:
                      steps:
                        type: array
                        description: The same rows the Steps tab endpoint returns.
                        items:
                          "$ref": "#/components/schemas/TrainingPathStepRow"
                      qa:
                        type: object
                        properties:
                          questions_count:
                            type: integer
                            example: 3
                          unanswered_count:
                            type: integer
                            example: 0
                      can_review:
                        type: boolean
                        example: false
                      my_review_id:
                        type: integer
                        nullable: true
                        example:
                      cta:
                        "$ref": "#/components/schemas/TrainingDetailCta"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
  "/training/courses/{course_id}/lessons":
    get:
      tags:
      - Training
      summary: Course lessons tab
      description: |
        The course detail's LESSONS TAB on its own — the native mirror of the web
        tab.

        The detail endpoint (`GET /training/courses/{id}`) already inlines this
        outline. This exists so a client can open or REFRESH just the tab — after
        finishing a lesson, most obviously — without refetching a detail payload
        that also carries sessions, prerequisites, custom fields, reviews and the
        CTA. The rows are produced by the same serializer the detail uses, so the
        two can never disagree.

        WHICH OUTLINE YOU GET depends on enrollment: an enrolled learner sees the
        course version their enrollment is PINNED to, so a mid-course republish
        never reshuffles the lessons under them; anyone else sees the currently
        published version.

        NOT PAGINATED, deliberately. Unlike reviews and Q&A, an outline is bounded
        by how the course was authored — tens of rows, not thousands — and the tab
        renders it whole, so paginating would buy a round trip and a scroll
        position to manage for nothing.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      responses:
        '200':
          description: Lesson outline retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    subject:
                      "$ref": "#/components/schemas/TrainingSubjectBlock"
                    enrolled:
                      type: boolean
                      description: Whether the caller holds an enrollment. When false
                        every row is `locked`, none is `current`, and `progress.completed_lessons_count`
                        is 0 — the browsing state.
                      example: true
                    progress:
                      type: object
                      description: The same two numbers the course detail reports,
                        from the same two sources. The detail spells them differently
                        — the total as `course.lessons_count`, and the pair again
                        inside `course.enrollment`, which is absent when the caller
                        is not enrolled. Here they are always present, so a client
                        rendering only the tab never has to special-case the browsing
                        state.
                      properties:
                        completed_lessons_count:
                          type: integer
                          example: 3
                        total_lessons:
                          type: integer
                          example: 8
                    lessons:
                      type: array
                      items:
                        "$ref": "#/components/schemas/TrainingLessonRow"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller (error code `not_found`)
            — unknown id, another tenant's course, or one that is not published.
  "/training/learning_paths/{learning_path_id}/steps":
    get:
      tags:
      - Training
      summary: Learning-path steps tab
      description: |
        The path detail's STEPS TAB on its own — the native mirror of the web tab,
        and the structural twin of the course Lessons tab above.

        The detail endpoint (`GET /training/learning_paths/{id}`) already inlines
        these steps; this is what a client re-fetches after finishing a member
        course, without pulling the whole detail back down. Same serializer as the
        detail, so the two agree by construction.

        READ `sequential` BEFORE RENDERING A PADLOCK: a non-sequential path never
        locks a step, so `locked` must not be inferred from position alone.

        Not paginated — a path has a handful of steps by construction.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Steps retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    subject:
                      "$ref": "#/components/schemas/TrainingSubjectBlock"
                    sequential:
                      type: boolean
                      description: Whether the path's steps unlock in order. What
                        makes `locked` on a step row meaningful.
                      example: true
                    enrolled:
                      type: boolean
                      description: Whether the caller holds the path enrollment. When
                        false nothing is locked and every course CTA is `enroll_required`.
                      example: true
                    progress:
                      type: object
                      description: The live required-work rollup, recomputed from
                        completed COURSES rather than read from the enrollment's cached
                        percentage — which goes stale when a member course is completed
                        outside the path.
                      properties:
                        percentage:
                          type: integer
                          example: 40
                        required_done:
                          type: integer
                          example: 2
                        required_total:
                          type: integer
                          example: 5
                        total_courses:
                          type: integer
                          example: 7
                        steps_complete:
                          type: integer
                          example: 1
                        first_incomplete_index:
                          type: integer
                          nullable: true
                          description: Zero-based; null once every step is complete.
                          example: 1
                    steps:
                      type: array
                      items:
                        "$ref": "#/components/schemas/TrainingPathStepRow"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such learning path visible to this caller (error code `not_found`)
            — unknown id, another tenant's path, or one that is not published.
  "/training/courses/{course_id}/lessons/{lesson_id}/content":
    get:
      tags:
      - Training
      summary: One lesson packaged for offline storage
      description: |
        ONE self-paced lesson as HTML the client can STORE ON THE DEVICE and render
        with no connection, plus everything needed to make that HTML work without
        us.

        Use this to download a lesson, cache a lesson for offline, sync course
        content, take a course offline, or prefetch lessons before going out of
        coverage.

        FOR THE ONLINE CASE YOU DO NOT NEED THIS. Load the row's `web_view_url` in
        a WebView instead — that is the same lesson rendered by the same view, with
        nothing to store and nothing to rewrite.

        WHAT THE CLIENT STILL HAS TO DO, in order:

        1. Download every url in `offline.stylesheets` and rewrite the `<link>`
           hrefs to the local copies. These are the SAME digested files the online
           webview links, so an offline lesson is styled by byte-identical CSS.
        2. Parse `offline.html` for `img[src]` and for `url(...)` inside `style`,
           download each, and rewrite to the local copy. `img` is the only media
           tag the body can contain — the sanitiser strips iframe, video, script
           and object — so there is nothing else in the markup to look for.
        3. Render `offline.document` / `offline.video` with the platform's own
           viewer. They are lesson-level attachments, NOT part of the body, so no
           amount of parsing the html would find them.

        `offline.document.url` AND `offline.video.url` ARE SIGNED AND EXPIRING.
        They are for the download pass only — never store the html still pointing
        at them, or the lesson works today and 404s next week. Re-fetch this
        endpoint to re-issue them.

        CHECK `offline.offline_supported` FIRST. Three kinds of lesson can never
        work offline and say so here rather than leaving each platform to derive
        it: `scorm` and `partner_course` (the runtime is on an external host,
        reached with a per-launch token, reporting progress by webhook) and an
        externally-hosted video (a YouTube/Vimeo embed). For those, fall back to
        `lesson.web_view_url` while online.

        A READ — no `write:training` scope needed, like every other Training read.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 91
      responses:
        '200':
          description: Lesson content retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    lesson:
                      allOf:
                      - "$ref": "#/components/schemas/TrainingLessonRow"
                      description: The same row shape the outline returns, so a client
                        can render the stored lesson's header without keeping a second
                        model. `current` is always false here — it is an outline-relative
                        flag.
                    offline:
                      "$ref": "#/components/schemas/TrainingOfflineLesson"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller, or no such lesson IN
            THAT COURSE (error code `not_found`). A real lesson id under the wrong
            course_id is a 404, not a redirect.
  "/training/courses/{course_id}/lessons/{lesson_id}/progress":
    post:
      tags:
      - Training
      summary: Record progress on one lesson
      description: |
        Record progress on a single lesson — mark it complete, save a video
        position, or add time spent.

        Use this to complete a lesson, mark a lesson done, save my place in a
        video, resume where I left off, or record study time.

        A SCORE CANNOT BE SENT HERE. A learner does not grade themselves, so this
        endpoint ignores `score` even if you include it; the field is read back on
        `TrainingLessonProgress` but is written only by SCORM/xAPI callbacks and
        partner-content sync.

        Writes the SAME `TrainingLessonCompletion` row the web writes
        (`learner/lessons#complete` and `#update_progress`) — no second notion of
        "done", so a learner who finishes on the phone is finished on the web, and
        the course's own progress percentage updates from the same callback.

        TWO RULES, both chosen because an offline client replays writes:

        * COMPLETION IS MONOTONIC. `completed: true` completes; nothing here
          un-completes. A stale queued write from before a reset can never revoke
          a completion the learner earned, and a double-tap or a retried flush is a
          no-op rather than a toggle. `completed_at` is not restamped on a replay.
        * VIDEO POSITION TAKES THE MAX. Two devices, or a queue flushed out of
          order, must not rewind the learner: sending 40 after 120 keeps 120. To
          move a learner BACKWARDS deliberately, the client tracks that locally —
          this endpoint will not do it.

        `time_spent` is a DELTA for this sitting, not a total — it is added to what
        is stored. Send it and your figure is authoritative; omit it and the server
        records wall-clock time from first open to completion, which is what the
        web records.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 91
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingLessonProgressInput"
      responses:
        '200':
          description: Progress recorded
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    progress:
                      "$ref": "#/components/schemas/TrainingLessonProgress"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '404':
          description: No such course visible to this caller, or no such lesson in
            that course (`not_found`).
        '422':
          description: |-
            `not_enrolled` — the caller holds no ACTIVE enrollment on the course, so there is nothing to record progress against. Enroll first (`POST /training/courses/{id}/enroll`).

            `invalid_progress` — a value was rejected by the model (a negative position or time). `progress_failed` — anything else; the write did not happen and may be retried.
  "/training/lessons/progress/sync":
    post:
      tags:
      - Training
      summary: Flush a batch of offline lesson progress
      description: |
        The OFFLINE FLUSH DOOR: a batch of the per-lesson writes above, for a client
        that has been recording progress with no connection.

        Use this to sync offline progress, upload queued progress, flush the
        offline queue, or catch up after reconnecting.

        PER-ITEM RESULTS, NEVER ALL-OR-NOTHING — and a client must read them that
        way. A queue that fails as a unit is a queue that cannot be drained: one
        lesson deleted by an admin, or one course the learner was unenrolled from,
        would block every other item behind it forever. Each entry reports its own
        `ok` plus an `error` code; drop what succeeded, and drop
        `lesson_not_found` / `course_not_found` / `not_enrolled` permanently
        (retrying will never succeed). Only `progress_failed` is worth re-queueing.

        Every item goes through the SAME write as the single-lesson endpoint, so
        the monotonic-completion and max-video-position rules hold identically —
        which is what makes flushing a queue twice safe.

        AT MOST 100 ITEMS per request; more is a `422`, not a truncation. Split the
        queue client-side.

        Requires `write:training`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - items
              properties:
                items:
                  type: array
                  maxItems: 100
                  description: The queued writes, in any order. Must be an array.
                  items:
                    allOf:
                    - type: object
                      required:
                      - course_id
                      - lesson_id
                      properties:
                        course_id:
                          type: integer
                          example: 37
                        lesson_id:
                          type: integer
                          example: 91
                    - "$ref": "#/components/schemas/TrainingLessonProgressInput"
      responses:
        '200':
          description: Batch processed. A 200 does NOT mean every item succeeded —
            read `failed` and the per-item `ok`.
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    synced:
                      type: integer
                      description: How many items were written.
                      example: 4
                    failed:
                      type: integer
                      description: How many were rejected. Non-zero with a 200 is
                        normal.
                      example: 1
                    results:
                      type: array
                      items:
                        type: object
                        properties:
                          index:
                            type: integer
                            description: Position in the `items` array you sent —
                              how to match a result back to a queue entry.
                            example: 2
                          lesson_id:
                            type: integer
                            nullable: true
                            example: 91
                          ok:
                            type: boolean
                            example: false
                          error:
                            type: string
                            nullable: true
                            enum:
                            - invalid_item
                            - course_not_found
                            - lesson_not_found
                            - not_enrolled
                            - invalid_progress
                            - progress_failed
                            description: Present only when `ok` is false. The first
                              four are PERMANENT — drop the queue entry. `progress_failed`
                              may be retried.
                            example: not_enrolled
                          progress:
                            allOf:
                            - "$ref": "#/components/schemas/TrainingLessonProgress"
                            description: Present only when `ok` is true.
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '422':
          description: "`invalid_items` — `items` was missing or not an array. `too_many_items`
            — more than 100 entries; `details` carries `limit` and `received`."
  "/training/courses/{course_id}/lessons/{lesson_id}/quiz":
    get:
      tags:
      - Training
      summary: Quiz card (assessment entry point)
      description: |
        Everything the learner sees BEFORE opening an attempt: the quiz's shape
        (question count, passing bar, time limit, points), their attempt tallies
        and remaining pool, their standing result, any attempt still open, and the
        ONE action to offer.

        This is the payload behind the assessment row in a course outline
        ("Safety Assessment · Quiz · 7 questions · Pass 80%") and behind the launch
        screen. Re-read it after submitting to refresh that row without refetching
        the whole course detail.

        **HOW A NATIVE CLIENT REACHES A QUIZ.** The learner does not arrive here
        from a list — they arrive from the lesson WebView, and the seam is a URL you
        intercept:

        1. Open the lesson row's `web_view_url` (the bare `?embed=1` /m/ render).
        2. It shows the quiz card, whose controls are plain GET links to
           `/m/apps/training/courses/{course_id}/lessons/{lesson_id}/quiz/launch`,
           carrying `?intent=` and — where one exists — `&attempt_id=`.
        3. Match that PATH in the WebView, ignoring the query string (`embed=1` and
           `mobile=1` also ride along), cancel the navigation, open your own screen.
        4. **`intent` says which screen to open**, so no probe request is needed. It
           is the same vocabulary as `cta.action` below, so one switch serves both
           doors:

           | `intent` | open | first call |
           |---|---|---|
           | `start` | question 1 | `POST .../quiz/attempts` |
           | `retake` | question 1 | `POST .../quiz/attempts` |
           | `resume` | resume at position | `POST .../quiz/attempts` → `resumed: true` |
           | `view_results` | the score | `GET /quiz_attempts/{attempt_id}/results` |

           `attempt_id` is present exactly for `resume` and `view_results`, which
           address an attempt directly — so those two skip this endpoint entirely.
           `locked` never appears: a blocked card renders prose, not a link.
        5. **Follow `cta.action` as given, including for an expired attempt.** When
           `open_attempt.expired` is true the CTA is `view_results`, and
           `GET /quiz_attempts/{id}/results` serves it: the attempt is graded from
           its saved answers on the way in and the normal results payload comes
           back with `attempt.state: "timed_out"` and `attempt.timed_out: true`. No
           client-side rule and no probe `POST` first. (Posting first still works
           and is what a `resume` CTA does — it replies 409 `attempt_timed_out`
           carrying `details.attempt_id` — so a client already written that way
           needs no change.)
        6. Treat `intent` as a **routing hint, not authority.** It is baked in when
           the card renders, so a card left open long enough can say `resume` for a
           clock that has since run out. Open the screen it names, then let the API's
           answer win — start replies 409 `attempt_timed_out` and you route to
           results instead. Same for "may attempt": don't carry it over from the
           webview, the server refuses on its own with 403 `attempt_blocked`.
        7. Run the attempt, then **reload the WebView when your screen closes** —
           passing writes the lesson completion and moves course progress, so the
           card underneath is stale until you do.

        Not intercepting is supported, not broken: `/quiz/launch` is a real route
        that starts or resumes the attempt and lands the learner in the responsive
        web player. It is a GET precisely so it *can* be intercepted — a POST form
        is not reliably visible to Android's `shouldOverrideUrlLoading`.

        **Address the quiz by its LESSON, not by the quiz id.** A course version
        can carry several quiz lessons, so the lesson id is what says which
        assessment is meant — and resolving through the lesson keeps a learner on a
        re-versioned course pinned to the copy they are actually taking.

        **Render `cta` rather than deriving one.** It resolves the same ladder the
        web launch card uses, so three clients cannot each invent their own:

        | `cta.action`   | meaning                                                        |
        |----------------|----------------------------------------------------------------|
        | `start`        | nothing attempted yet — POST an attempt                         |
        | `resume`       | an attempt is open; `attempt_id` is it                          |
        | `retake`       | graded, and another attempt is allowed                          |
        | `view_results` | graded, and no further attempt is allowed (`reason` says why)   |
        | `locked`       | nothing graded and no attempt allowed (`reason` says why)       |

        `attempts.remaining` is **null, not 0**, when the quiz has no attempt
        limit — an unlimited pool has no remainder, and 0 reads as exhausted.

        `outcome` is the attempt the learner's Pass/Fail VERDICT came from, which
        is not necessarily the most recent one: on a `highest` score policy a
        failing retake leaves the verdict on the earlier pass. Do not infer the
        standing result from the newest attempt.

        `outcome.effective_score` is the score that COUNTS toward completion and
        the transcript, under the quiz's score policy (highest / latest / average /
        first). It can differ from `outcome.score_percentage`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        description: The QUIZ lesson's id. A non-quiz lesson 404s.
        schema:
          type: integer
          example: 412
      responses:
        '200':
          description: Quiz card retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  enrollment:
                    "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
                  card:
                    "$ref": "#/components/schemas/TrainingQuizCard"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`).
        '404':
          description: "`not_enrolled` — the caller holds no enrollment for this course,
            so the assessment is not yet something they can see. Also `not_found`
            for a course outside the caller's business, an unpublished or archived
            course, a lesson that is not in the learner's pinned version, or a lesson
            that carries no quiz. All four answer alike, so none reveals the others."
  "/training/courses/{course_id}/lessons/{lesson_id}/quiz/attempts":
    post:
      tags:
      - Training
      summary: Start or resume a quiz attempt
      description: |
        Returns the learner's live attempt, creating one only if there isn't
        already one open — and the full question set with it, so the player needs
        no second request to begin.

        **Safe to retry.** A double tap, or a client retrying a request that timed
        out, cannot mint a second attempt or burn a slot out of the attempt pool:
        an open attempt is RESUMED. `resumed` tells you which happened, so the UI
        can say "resuming" instead of implying a fresh clock. A resumed attempt
        keeps the edition, the question order and the answers it already had.

        **200, not 201**, precisely because the common case is a resume — the
        status code would be a worse signal than `resumed`, which is unambiguous.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 412
      responses:
        '200':
          description: Attempt started or resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  resumed:
                    type: boolean
                    description: True when an attempt was already open and this returned
                      it rather than creating one. Its clock has been running since
                      `attempt.started_at`.
                    example: false
                  attempt:
                    "$ref": "#/components/schemas/TrainingQuizAttempt"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`); the token lacks `write:training`
            (`insufficient_permissions`); or `attempt_blocked` — a well-formed request
            the learner may not make. `error.message` is the specific gate, written
            for a learner: the attempt pool is used up, they have already passed and
            the quiz only allows retakes after a fail, or a cooldown is still running.
            403 rather than 422 because nothing about the request is malformed.'
        '404':
          description: As for the card — `not_enrolled` or `not_found`.
        '409':
          description: |-
            `attempt_timed_out` — the learner had an attempt still open whose clock had already run out. **This request graded it** from the answers it had saved, so there is nothing to resume. `error.details.attempt_id` names it: send the learner to that attempt's results, then let them choose Retake from there (which the results payload gates for you).

            You can see this coming on the card — `open_attempt.expired` is true — and a client that checks it can label its button "View result" instead of "Resume" and skip this round trip. Do not treat it as an error to retry; retrying returns the same 409 until the learner starts a new attempt.
        '422':
          description: "`no_questions` — the quiz has been published with nothing
            in it, so no edition can be sealed and there is nothing to take. `error.message`
            says so in words meant for the learner; show it rather than a generic
            error, and treat it as an admin problem, not a transient one worth retrying."
  "/training/quiz_attempts/{id}":
    get:
      tags:
      - Training
      summary: The live quiz player
      description: |
        The open attempt and every question to render, in the order frozen onto the
        attempt when it started.

        **The whole set comes at once**, not one question per request. The
        assessment screen pages between questions with no loading state, answers
        autosave in the background, and the set is already frozen — so per-question
        fetching would put a network round trip inside the only interaction the
        screen has, and could not surface a newer question anyway.

        Read the answer-stripping contract at the top of this section before
        rendering `questions`: nothing here identifies a correct answer, and for
        `ranking`, `matching_*` and `hotspot` that has consequences for how you
        display and post each one.

        `questions[].answer` echoes what the learner has already entered, in the
        SAME form you post it — so a resumed attempt rehydrates. For `matching_*`
        that echo is in tokens, not in the ids the server stores internally.

        This endpoint refuses a finished attempt (409) rather than handing back a
        quiz the learner can no longer answer; the results endpoint is its
        counterpart, and each refuses the other's state.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 9014
      responses:
        '200':
          description: Attempt retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  lesson_id:
                    type: integer
                    nullable: true
                    example: 412
                  attempt:
                    "$ref": "#/components/schemas/TrainingQuizAttempt"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`).
        '404':
          description: "`not_found` — no such attempt for this caller."
        '409':
          description: |-
            The attempt is over, so there is no player to render. Two codes, both carrying `details.attempt_id` (send the learner to its results) and `details.block_reason` when a further attempt is refused:

            `attempt_finished` — it was already submitted.

            `time_expired` — the clock had run out. **This request finalized it**, grading whatever was last autosaved, so the attempt is now `timed_out`.
  "/training/quiz_attempts/{id}/answers":
    patch:
      tags:
      - Training
      summary: Autosave answers on an open attempt
      description: |
        Persist answers-in-progress. Fire this on every question change (debounce
        it when the whole quiz is on one page) so backgrounding the app, losing
        signal or a crash never loses answered work — and so a timed attempt that
        expires is graded from something real.

        Deliberately does **not** score, change the attempt's state, or return the
        questions. Submit remains the authority.

        Send the full `answers` object each time, not a delta: it REPLACES what was
        stored. `position` records where the learner was, so a resume reopens on
        that question.

        Returns the saved tally (`answered_count` / `question_count`) so a client
        can drive an "N of 7 answered" line — including the one on the exit
        confirmation — without counting locally. "Answered" is judged per type: a
        `true_false` of `false` counts, a `matching_*` needs at least one filled
        pair, and a `ranking` always counts because it always has an order.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The attempt id.
        schema:
          type: integer
          example: 9014
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                position:
                  type: integer
                  description: 1-based question index the learner is on.
                  example: 3
                answers:
                  "$ref": "#/components/schemas/TrainingQuizAnswers"
      responses:
        '200':
          description: Answers saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved:
                    type: boolean
                    example: true
                  attempt:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 9014
                      current_position:
                        type: integer
                        nullable: true
                        example: 3
                      answered_count:
                        type: integer
                        example: 2
                      question_count:
                        type: integer
                        example: 7
                      time_remaining_seconds:
                        type: integer
                        nullable: true
                        description: Null on an untimed quiz.
                        example: 742
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or the token lacks `write:training`.
        '404':
          description: "`not_found` — no such attempt FOR THIS CALLER, or its course
            is no longer visible. Another learner's attempt answers identically."
        '409':
          description: "`attempt_closed` — the attempt has already been submitted
            or timed out, so nothing was saved and the stored answers are untouched.
            Stop retrying and take the learner to the results."
  "/training/quiz_attempts/offline":
    post:
      tags:
      - Training
      summary: Replay a quiz attempt taken offline (untimed quizzes only)
      description: |
        One shot: create AND grade an attempt from answers the learner gave with no
        connection, and return the same result payload `submit` does — score, review
        and the learner's new standing in the course. A pass completes the quiz's
        lesson exactly as the online path does.

        **Untimed quizzes only.** A quiz with `time_limit_minutes` is refused with
        `offline_not_permitted_timed` and nothing is created: the clock runs on the
        server from `started_at` and cannot be honest about minutes that passed on a
        device. Read `quiz.offline_allowed` on the quiz card, or
        `offline.quiz` on the lesson content payload, to know in advance.

        **Idempotent on `client_attempt_id`.** Generate a UUID on the device when the
        learner starts, send it with every flush: a retried request returns the
        original result with `outcome: replayed` and `replayed: true`, and spends no
        second attempt. Nothing about the payload is re-graded on a replay.

        **Entitlement is re-checked at replay.** A learner who is out of attempts,
        already passed under `only_after_fail`, or inside a cooldown gets `403
        attempt_blocked` with the same prose the card shows — and nothing is created,
        so the client shows the refusal and discards its local draft.

        **Key `answers` by the question ids from `offline.quiz.questions`** on the
        lesson content payload (the quiz's sealed edition). Shapes per question type
        are the ones `PATCH /quiz_attempts/{id}/answers` accepts — matching via the
        opaque tokens, ranking as an ordered id list, hotspot as `[{x, y}]`. Mandatory
        questions left unanswered are refused with `unanswered_mandatory` and nothing
        is created.

        `started_at` / `finished_at` are the device's timestamps and become the
        attempt's clock (bounded to the last 30 days, never the future).

        Requires `write:training`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - lesson_id
              - client_attempt_id
              - answers
              properties:
                lesson_id:
                  type: integer
                  example: 1463
                course_id:
                  type: integer
                  description: Optional — derived from the lesson when absent.
                  example: 572
                client_attempt_id:
                  type: string
                  maxLength: 64
                  description: A UUID minted on the device when the learner started.
                  example: 2f4d1c0a-6f4e-4d2e-9b1a-3c9f0e7d5a11
                started_at:
                  type: string
                  format: date-time
                  example: '2026-09-07T09:12:00Z'
                finished_at:
                  type: string
                  format: date-time
                  example: '2026-09-07T09:26:40Z'
                answers:
                  type: object
                  additionalProperties: true
                  description: Keyed by question id, in the per-type shapes the answers
                    endpoint accepts.
                  example:
                    '9101': a
                    '9102':
                    - c
                    - a
                    - b
      responses:
        '200':
          description: The attempt was created and graded (or, on a replay, the original
            result)
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome:
                    type: string
                    enum:
                    - submitted
                    - replayed
                  replayed:
                    type: boolean
                    description: True when this client_attempt_id had already been
                      flushed — the original result, nothing consumed.
                  result:
                    "$ref": "#/components/schemas/TrainingQuizResult"
                  course:
                    "$ref": "#/components/schemas/TrainingQuizCourseProgress"
        '403':
          description: "`attempt_blocked` — attempts used up, already passed, or cooling
            down; nothing created"
        '404':
          description: Not enrolled, or no quiz on this lesson
        '422':
          description: "`offline_not_permitted_timed` (a timed quiz), `unanswered_mandatory`,
            `invalid_client_attempt_id` or `no_questions`; nothing created"
  "/training/quiz_attempts/{id}/submit":
    post:
      tags:
      - Training
      summary: Submit a quiz attempt for grading
      description: |
        Grade and close the attempt, and return the full result — score, review and
        the learner's new standing in the course — so the results screen needs no
        follow-up request.

        Key the `answers` object by the question ids THIS attempt's player
        returned. See the answer-stripping and edition notes at the top of this
        section; ids from anywhere else will report every question unanswered.

        **Passing completes the quiz's lesson**, which moves course progress and,
        when this was the last outstanding requirement, completes the enrollment.
        The `course` block reports that new state, which is what tells a client
        whether to show a course-completion screen. `course.certificate.status` is
        `pending` right after such a completion because issuing it is asynchronous —
        poll `GET /training/my_records/certificates`; do not read a missing id as
        "no certificate".

        **`outcome` has two values.** `submitted` is the normal path. `timed_out`
        means the clock had already run out: the attempt was graded from the last
        autosave and the payload just sent was ignored. Show "time's up" rather
        than presenting it as a normal submission.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 9014
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                answers:
                  "$ref": "#/components/schemas/TrainingQuizAnswers"
      responses:
        '200':
          description: Attempt graded
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome:
                    type: string
                    enum:
                    - submitted
                    - timed_out
                  result:
                    "$ref": "#/components/schemas/TrainingQuizResult"
                  course:
                    "$ref": "#/components/schemas/TrainingQuizCourseState"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or the token lacks `write:training`.
        '404':
          description: "`not_found` — no such attempt for this caller."
        '409':
          description: "`already_submitted` — a double submit, a stale screen, or
            a retry after the attempt was graded. **The answers in this request were
            NOT recorded** and the earlier grade stands. Say so rather than quietly
            showing the old results."
        '422':
          description: "`unanswered_mandatory` — one or more required questions are
            blank. Nothing was graded and the attempt stays open. The submitted answers
            were kept as a draft, so the learner loses nothing. `error.details.unanswered_question_ids`
            names the questions and `error.details.unanswered_count` how many, so
            a client can jump the learner straight to the first one."
  "/training/quiz_attempts/{id}/results":
    get:
      tags:
      - Training
      summary: Results of a graded attempt
      description: |
        The post-submit review: the score banner, the per-question outcome list,
        every graded attempt for the switcher, and whether a retake is still on
        offer.

        Attempt-addressed, so a learner can look back at any of their own graded
        attempts — which is what the `result.attempts.graded` list is for. The
        entry with `current: true` is the one being shown.

        **An expired attempt is graded on the way in.** A timed attempt left open
        past its limit is still `in_progress` with a null `submitted_at` until
        something finalizes it, and this endpoint does: it grades the answers that
        were autosaved, writes the quiz-lesson completion when the score clears the
        bar, and renders the normal payload with `attempt.state: "timed_out"` and
        `attempt.timed_out: true`. So the `view_results` CTA the quiz card emits for
        such an attempt can be followed directly. Repeating the call is safe — the
        attempt is already terminal, so nothing is re-graded or re-stamped.

        **`review` can be null.** Answer reveal is a per-quiz setting frozen with
        the attempt's edition, so an admin turning it off cannot retroactively blank
        a review a learner has already seen, and turning it on cannot reveal answers
        for an attempt taken under the old rule. When it is null,
        `review_hidden_reason` is `answers_not_revealed` — render that, not an empty
        list, which reads as a bug.

        **The score's three counts are reported separately on purpose.** Written
        (`text`) answers are graded by a human and are excluded from BOTH sides of
        the score, so `correct_count` / `auto_graded_count` / `pending_review_count`
        are given rather than a single "N of M correct" that would silently count a
        question nobody has read. Compose the sentence from figures that add up, and
        render a `pending_review` row as awaiting review — never as incorrect.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 9014
      responses:
        '200':
          description: Results retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  lesson_id:
                    type: integer
                    nullable: true
                    example: 412
                  result:
                    "$ref": "#/components/schemas/TrainingQuizResult"
                  course:
                    "$ref": "#/components/schemas/TrainingQuizCourseState"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`).
        '404':
          description: "`not_found` — no such attempt for this caller."
        '409':
          description: "`attempt_not_submitted` — the attempt is still LIVE (its clock
            is running and it has no score yet). Send the learner to `GET /training/quiz_attempts/{id}`
            to go on answering. An open attempt whose time limit has EXPIRED is not
            refused: it is graded from its saved answers and returned as a normal
            200 with `attempt.timed_out: true`."
  "/training/courses/{course_id}/lessons/{lesson_id}/scorm":
    get:
      tags:
      - Training
      summary: SCORM card (courseware launch panel)
      description: |
        Everything the learner sees BEFORE launching a courseware module: what the
        package is, whether it can be played at all, their status and score, where
        they left off, and the ONE control to offer — plus the url that opens the
        real player.

        This is the payload behind the prototype's launch panel for a package
        lesson, and the native mirror of the two web launch cards. Re-read it every
        time your runtime WebView closes: the player writes progress straight onto
        the lesson completion, so the card underneath is stale until you do.

        **THIS ENDPOINT DOES NOT HAND YOU CONTENT TO RENDER, AND CANNOT.** A quiz
        is JSON, so a native screen can take it. A SCORM / xAPI / cmi5 / AICC
        package is a folder of third-party HTML and JavaScript that talks to a
        runtime API on this origin (SCORM API calls, the LRS, AICC HACP) — there is
        no payload that would let an app play it. So:

        1. Draw the card from this payload.
        2. When the learner taps the primary control, open `cta.web_view_url` in a
           WebView. It is the chrome-less full-frame player (`?embed=1` strips the
           header, tab bar and prev/next so your own title bar is the only one).
           **That request is what starts the attempt** — it mints the courseware
           registration and a single-use per-launch session — so do not prefetch
           it, and do not open it to "check" the card.
        3. Draw your own exit affordance. The player also carries a "Return to
           course" control which navigates the WebView to the lesson page
           (`/m/apps/training/courses/{course_id}/lessons/{lesson_id}?embed=1`) —
           match that path to close your screen instead.
        4. Reload the card when your screen closes.

        **`cta.web_view_url` is null whenever there is nothing to open**, so a
        client that renders the button only when it is present can never offer a
        control that lands on a refusal. `cta.reason` says why, and `cta.message`
        is prose written for a learner — show it.

        | `cta.action`  | meaning                                                        |
        |---------------|----------------------------------------------------------------|
        | `launch`      | never opened — open `web_view_url`                              |
        | `resume`      | opened before and unfinished; `resume` says where they were     |
        | `review`      | the module is complete — it re-opens READ-ONLY (see below)      |
        | `processing`  | the package is still importing — no launch yet, check back      |
        | `unavailable` | it cannot be played (`reason` says why)                         |

        **A COMPLETED MODULE IS REVIEW-ONLY, and that is a rule about the record,
        not a label.** The moment the lesson completes, its score and training
        time are frozen: the runtime keeps working (it still bookmarks, still
        shows its own score inside the player) but nothing it reports afterwards
        can move `completion.score` or `completion.time_spent_seconds`. So do not
        present `review` as a retake or offer a "try for a better score" — the
        learner cannot earn one, and a client that implies otherwise is lying to
        them. A genuine second attempt is a course RETAKE, which mints a new
        enrollment attempt and a fresh record.

        `cta.secondary` is the "Start over" that discards the bookmark
        (`POST .../scorm/restart`). It appears **only while the lesson is
        unfinished** and there is something to discard — never on a `review`
        card, for the reason above. Both web cards and the player's own menu
        follow the identical rule, so a learner never sees it in one place and
        not another.

        **Render `package.launchable`, never `package.status`.** `status` is the
        LESSON's import state; `launchable` is whether a launch can actually
        produce a player (the package imported AND its files landed, or an AICC
        unit that runs on the provider). They disagree in real data — a tenant
        carrying lessons from before the native engine has them stamped `ready`
        with no package at all — and `launchable` is the gate the launch itself
        applies.

        **There is no attempt pool.** Courseware has no attempt limit anywhere in
        the model: one registration exists per lesson attempt and each launch opens
        a SESSION on it, which is what `resume.launches` counts. Do not render
        "attempt 1 of 3" for a module.

        **Courseware is online-only** (`offline.supported: false`). The runtime is
        server-side, so there is nothing a client can store and play on a train —
        unlike a text, video or document lesson, which
        `GET .../lessons/{lesson_id}/content` packages for offline use.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 81
      - name: lesson_id
        in: path
        required: true
        description: The SCORM lesson's id. A lesson of any other content type 404s
          — a course version can carry several courseware lessons, so the lesson id
          is what says which module is meant.
        schema:
          type: integer
          example: 289
      responses:
        '200':
          description: SCORM card retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  enrollment:
                    "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
                  card:
                    "$ref": "#/components/schemas/TrainingScormCard"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`).
        '404':
          description: "`not_enrolled` — the caller holds no enrollment for this course,
            so its modules are not yet something they can open. Also `not_found` for
            a course outside the caller's business, an unpublished or archived course,
            a lesson that is not in the learner's pinned version, or a lesson that
            is not a SCORM lesson. All of them answer alike, so none reveals the others."
  "/training/courses/{course_id}/lessons/{lesson_id}/scorm/restart":
    post:
      tags:
      - Training
      summary: Start a courseware module over
      description: |
        Drops this attempt's courseware registration — the bookmark, the suspend
        data, the runtime's score and the session history — so the next launch
        plays from the beginning. The card's `cta.secondary` is what offers it,
        and the desktop card's "Start over" button is the same operation through
        the same service.

        **ONLY WHILE THE LESSON IS UNFINISHED.** Once it is complete this answers
        403 `lesson_completed` and changes nothing. That is not a permission quirk
        — a completed lesson's score and training time are frozen, so a reset
        there would throw away the learner's place and then record nothing from
        the replay. A finished module is review-only; a real do-over is a course
        retake (new attempt, new record) or an admin reset from Courseware
        activity, which is the one door allowed to clear a finished learner.

        **A recorded completion STAYS recorded.** Even on an unfinished lesson
        this resets where the learner is, never what has been recorded: the
        completion row, the course progress it drives and any certificate it
        earned are untouched. Same contract in both web doors.

        **Idempotent.** With nothing to reset it removes nothing and still answers
        200 — `reset` says which happened, so a client retrying a dropped request
        cannot do damage and does not have to guess.

        Returns the refreshed CARD, so redraw from this response rather than firing
        a second GET to discover that Resume has become Launch.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 81
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 289
      responses:
        '200':
          description: Progress reset (or nothing to reset)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reset:
                    type: boolean
                    description: True when a registration was actually dropped. False
                      means there was nothing to reset — the module had never been
                      launched, or a previous call already did it.
                    example: true
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  enrollment:
                    "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
                  card:
                    "$ref": "#/components/schemas/TrainingScormCard"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`); the token lacks `write:training`
            (`insufficient_permissions`); `lesson_completed` — the module is already
            complete and is review-only, so there is nothing to start over (the card
            shows no `cta.secondary` in this state); or `enrollment_cancelled` — the
            attempt has been cancelled, so there is nothing to record a replay against.
        '404':
          description: As for the card — `not_enrolled` or `not_found`.
        '409':
          description: "`package_processing` / `package_stalled` / `package_unavailable`
            — the module cannot be played, so its resume state is left alone rather
            than wiped for something the learner cannot reopen. The same code the
            card's `cta.reason` carries; redraw the card rather than retrying."
  "/training/courses/{course_id}/reviews":
    get:
      tags:
      - Training
      summary: Course ratings & reviews
      description: |
        The FULL Ratings & Reviews list for a course — the "See all" sheet behind
        the summary block on the course detail. Paginated and sortable.

        The detail endpoint (`GET /training/courses/{id}`) already inlines the
        newest 5 reviews plus the same `rating` summary; this is what the client
        opens for the rest. The summary is repeated because the sheet renders its
        own histogram header.

        APPROVED reviews only — the same filter the web block applies. Ordering:
        `recent` (newest first, the default) and `lowest` (lowest rating first,
        for a learner looking for the caveats before starting).

        There is no `helpful` sort and no `helpful_count` on a row. The Helpful
        affordance is admin-only on the web — its button and count live on the
        admin reviews index, which no learner surface links to — so a
        learner-facing list neither displays that number nor orders by it.
        `sort=helpful` is treated as unrecognised and falls back to `recent`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: sort
        in: query
        required: false
        description: Unrecognised values fall back to `recent`.
        schema:
          type: string
          enum:
          - recent
          - lowest
          default: recent
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Reviews retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Rate a course
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Post the caller's review of a course — the native mirror of the learner's
        inline "Rate this course" modal on the web About tab.

        **Eligibility** is the same rule the GET reports as `can_review`: the
        caller must be ENROLLED and must not have reviewed this course already.
        A client that honours `can_review` never hits the two 422s below; they
        exist for a stale screen, and they carry DIFFERENT codes because the
        client's next move differs:

        * `already_reviewed` — offer *Edit your review* instead. (A review still
          awaiting moderation counts, so this fires whenever `my_review` is set.)
        * `not_enrolled` — offer *Enroll* instead.

        **One review per learner per course**, enforced by both a model
        validation and a unique index. There is no POST-to-update: re-posting is
        `already_reviewed`.

        **Verified purchase** is stamped server-side when the caller bought the
        course, exactly as on the web — clients neither send nor control it.

        **Moderation**: reviews are approved on creation (the column default), so
        the new review is live and appears in the very next GET of this list. The
        response returns the recomputed `rating` histogram, so a client refreshes
        its summary block without a second call.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewCreateRequest"
      responses:
        '201':
          description: Review posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller (error code `not_found`).
        '422':
          description: |
            Not eligible (`already_reviewed`, `not_enrolled`) — both in the
            standard `{ error: { code, message } }` envelope — **or** the body
            failed validation (a missing/out-of-range `rating`, an over-long
            `title`/`content`), which answers with the platform's per-field
            `{ errors: [{ field, message, code }] }` shape instead.
  "/training/learning_paths/{learning_path_id}/reviews":
    get:
      tags:
      - Training
      summary: Learning-path ratings & reviews
      description: |
        The FULL Ratings & Reviews list for a learning path. Identical payload and
        parameters to the course variant above (`subject.type` is `path`) — reviews
        are polymorphic, so one controller serves both.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      - name: sort
        in: query
        required: false
        description: Unrecognised values fall back to `recent`.
        schema:
          type: string
          enum:
          - recent
          - lowest
          default: recent
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Reviews retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Rate a learning path
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Post the caller's review of a learning path. Identical body, payload and
        error codes to the course variant above — reviews are polymorphic, so one
        controller serves both.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewCreateRequest"
      responses:
        '201':
          description: Review posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
        '422':
          description: |
            Not eligible (`already_reviewed`, `not_enrolled`), or the body failed
            validation — see the course variant above for both envelopes.
  "/training/courses/{course_id}/questions":
    get:
      tags:
      - Training
      summary: Course Q&A
      description: |
        The Course Q&A screen — question threads with their answers, the native
        mirror of the Q&A tab on the web course page.

        Each thread carries its answers ordered best-answer-first then
        most-upvoted (the web's exact sort), plus `best_answer` as a pointer into
        that array for clients showing a single answer.

        `filter` is the tab's three pills: `all` (newest first, the default),
        `unanswered`, and `top` (most upvoted). `counts` is filter-INDEPENDENT so
        one request badges all three.

        The detail endpoint's `qa: { questions_count, unanswered_count }` block is
        the tab badge; this is the tab's contents.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: filter
        in: query
        required: false
        description: Unrecognised values fall back to `all`.
        schema:
          type: string
          enum:
          - all
          - unanswered
          - top
          default: all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Q&A retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQuestionsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Ask a question, or reply to one
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        **ONE endpoint for both halves of a Q&A thread**, because they are one
        composer to the client:

        * omit `question_id` → ASK a new question on this course
        * pass  `question_id` → REPLY (post an answer) to that question

        Mirrors the web's two actions (ask + answer) on the same screen.

        **Threading is one level**, structurally: an answer has no parent answer,
        so there is no reply-to-a-reply case. `question_id` is resolved through
        THIS course's own thread — an id belonging to another course, another
        learning path or another tenant is 422 `question_not_found`, never a
        misfiled answer.

        **No per-owner permission**: any Training user in the business may ask and
        answer (`permissions.can_ask` / `can_answer` are true for anyone holding a
        200 on the GET). Only marking a best answer is gated — see
        `POST /training/qa/answers/{id}/mark_best`.

        **No notifications** are sent, matching the web: an instructor finds
        unanswered questions from the tab's own `counts.unanswered` badge.

        The response is the WHOLE updated thread, not just the row written, so one
        response re-renders the card — posting an answer also flips the question's
        `status` to `answered` and re-sorts `answers`. `answer_id` names the row
        just created (null when a question was asked) so a client can highlight it
        without diffing.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingQaPostRequest"
      responses:
        '201':
          description: Question asked, or answer posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQaThread"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such course visible to this caller (error code `not_found`).
        '422':
          description: |
            `body_required` (nothing but whitespace was sent) or
            `question_not_found` (`question_id` isn't a question on this course),
            both in the standard `{ error: { code, message } }` envelope — or a
            model validation failure (body over its length cap) in the per-field
            `{ errors: [...] }` shape.
  "/training/learning_paths/{learning_path_id}/questions":
    get:
      tags:
      - Training
      summary: Learning-path Q&A
      description: |
        The Q&A thread list for a learning path. Identical payload and parameters
        to the course variant above (`subject.type` is `path`) — Q&A is
        polymorphic, so one controller serves both.

        One difference in the DATA, not the shape: `author.instructor` is always
        false here. The Instructor role is per-COURSE; a learning path has no
        equivalent, which is why the web badge never appears on a path thread
        either.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      - name: filter
        in: query
        required: false
        description: Unrecognised values fall back to `all`.
        schema:
          type: string
          enum:
          - all
          - unanswered
          - top
          default: all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Q&A retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQuestionsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Ask a question on a learning path, or reply to one
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Identical body, payload and error codes to the course variant above — Q&A
        is polymorphic, so one controller serves both. Omit `question_id` to ask,
        pass it to reply.

        One difference in the DATA, not the shape: `permissions.can_mark_best` is
        true for a Training admin or the PATH'S CREATOR here, because the
        Instructor role is per-COURSE and a path has no equivalent (the same
        branch the web tab takes).
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingQaPostRequest"
      responses:
        '201':
          description: Question asked, or answer posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQaThread"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
        '422':
          description: |
            `body_required` or `question_not_found`, or a model validation
            failure — see the course variant above for both envelopes.
  "/training/qa/{votable_type}/{id}/vote":
    post:
      tags:
      - Training
      summary: Toggle an upvote on a question or an answer
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        **TOGGLE** the caller's upvote ("helpful") on a question OR an answer —
        the native mirror of the web's two upvote buttons, which are the same
        toggle on the same polymorphic vote record.

        ONE endpoint, with `votable_type` selecting the target, for the same
        reason one controller serves both owner types on the read side: the vote
        is polymorphic, and the web's two actions differ only in which row they
        load.

        **A toggle, not a POST/DELETE pair** (unlike the Ideas vote API): a
        Training upvote un-votes on a second press on every surface, and the tap a
        client is mirroring doesn't know which direction it is going. So this is
        **NOT idempotent** — two calls return to the starting state — and the
        response always reports the RESULTING state read back from the database.
        Patch your cached row from `votes_count` / `my_vote`; never predict them.

        No per-owner permission — upvoting is open to any Training user in the
        business, exactly as on the web.
      security:
      - BearerAuth: []
      parameters:
      - name: votable_type
        in: path
        required: true
        description: Which kind of row to toggle. Anything other than these two words
          does not match the route and 404s at the router.
        schema:
          type: string
          enum:
          - questions
          - answers
      - name: id
        in: path
        required: true
        description: The question's or answer's own id (not the course's).
        schema:
          type: integer
          example: 30
      responses:
        '200':
          description: Upvote toggled; the resulting state is returned.
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  required:
                  - votable
                  properties:
                    votable:
                      "$ref": "#/components/schemas/TrainingQaVoteState"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (error code
            `access_denied`).
        '404':
          description: No such question/answer in the caller's business, or its course
            / learning path is no longer visible to the caller (error code `not_found`
            for both — neither answer reveals the other).
  "/training/qa/answers/{id}/mark_best":
    post:
      tags:
      - Training
      summary: Mark an answer as the best answer
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Mark one answer as its question's BEST answer — the native mirror of the
        web Mark-best affordance. Clears whichever answer held the flag before and
        flips the question to `answered`, in one transaction.

        **The one Q&A action with a per-owner permission**: the owner's content
        manager only — a Training admin, the COURSE's Instructor, or (for a
        learning path, which has no per-path role) the path's creator. Learners
        may ask, answer and upvote; deciding which answer is authoritative is
        moderation.

        The Q&A GET reports the same decision as `permissions.can_mark_best`, so
        render the affordance from that flag rather than from the existence of
        this endpoint — otherwise every learner sees a button that 403s on tap.

        **Not a toggle**: there is no un-mark on any surface. Re-marking the answer
        that already holds the flag is a successful no-op; marking a DIFFERENT
        answer moves the flag, which is how a mistake is corrected.

        Answers with the updated thread — the same payload the Q&A POST returns —
        because marking a best answer re-sorts `answers` (best first) and can
        change the question's `status`, so a narrower response would leave the
        card wrong.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The answer's own id.
        schema:
          type: integer
          example: 23
      responses:
        '200':
          description: Best answer set; the updated thread is returned.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQaThread"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`),
            or the caller is not a content manager of the answer's course / learning
            path (`forbidden`).
        '404':
          description: No such answer in the caller's business, or its course / learning
            path is no longer visible to the caller (error code `not_found`).
        '422':
          description: "`mark_best_failed` — two managers marked a best answer on
            the same question at the same moment and the unique index rejected this
            one. Retrying succeeds; re-offer the action."
  "/training/my_team":
    get:
      tags:
      - Training
      summary: My Team dashboard (one tab per request)
      description: |
        The manager surface: the four stat tiles, the three pill counts, and **one
        page of the active tab**.

        **Fetch the tab you are showing.** `tab` selects which list comes back —
        `people` (the roster, the default), `overdue`, or `done` — and `page` /
        `per_page` paginate **that** list, with `meta` describing it. An
        unrecognised `tab` falls back to `people`, and `active_tab` echoes what was
        actually applied.

        **Named keys, one populated.** `members`, `overdue` and `completions`
        always all appear; only the active tab's carries rows. That keeps the
        element type of each key stable for a generated client, which a single
        polymorphic `rows` key would not. Read `active_tab` to know which to use.

        **`stats` and `pills` are TAB-INDEPENDENT**, so one request still badges
        all three pills and switching tabs needs no second count.

        **SCOPE: the role sets the default AND the ceiling.** An admin/HR
        admin/Training app admin defaults to every active member of the business;
        a people-manager sees their direct reports. `scope` reports what was
        actually applied:

        - `scope.key` — `all_employees` or `direct_reports`, the scope in force.
        - `scope.available` — what THIS caller may ask for: both values for an
          admin, `["direct_reports"]` alone for a manager, so a client renders the
          segmented control only when there is a real choice.
        - `scope.admin_view` — whether these rows are the whole business. Derived
          from the APPLIED scope, not from the role, so an admin who has narrowed
          to their reports gets the "Team" noun rather than "Employees".

        Pass `scope` to switch. An admin may narrow to `direct_reports`; a manager
        asking for `all_employees` is served their reports regardless — the role is
        a ceiling, not a suggestion — and `scope.key` echoes what was applied.
        Unrecognised values fall back to the default, the same convention `tab`
        uses.

        The drill-in (`/training/my_team/{id}`) deliberately IGNORES `scope`: it
        resolves through the caller's entitled scope, so narrowing the dashboard
        never turns another employee's row into a 404.

        `scope.total` carries the true headcount beside the page — what a
        "Showing 8 of 142" caption needs. `stats.team_size` is that same total.
        `pills[].key` values are stable and match the `tab` values.

        The `overdue` and `done` lists span the WHOLE team, not the roster page,
        because their pill counts the team — a list that disagreed with its own
        badge would be worse than a slow one.

        > **Why one tab per request.** An earlier revision returned all three
        > lists every time and fed each of them the whole team's ids. On a
        > 41,260-person tenant that measured 8.1 s, 157 queries and 2.5 MB of SQL
        > text — eight statements carrying a 41,262-element `IN (...)` list — to
        > produce a 15 KB payload. Per-tab fetching plus subquery filtering brings
        > the same request to ~110 ms, and nothing on the path scales with
        > headcount.
      security:
      - BearerAuth: []
      parameters:
      - name: tab
        in: query
        required: false
        description: Which list to return. Matches the `pills[].key` values. Unrecognised
          values fall back to `people`.
        schema:
          type: string
          enum:
          - people
          - overdue
          - done
          default: people
      - name: scope
        in: query
        required: false
        description: Which team to report on. `all_employees` is admin-only and is
          an admin's default; `direct_reports` is a manager's only option and their
          default. A manager passing `all_employees` is still served their direct
          reports (the role is a ceiling). Unrecognised values fall back to the caller's
          default. `scope.key` in the response echoes what was applied, and `scope.available`
          lists what this caller may ask for.
        schema:
          type: string
          enum:
          - direct_reports
          - all_employees
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Page size for the ACTIVE tab. Clamped to 1..50; anything unparseable
          falls back to 20.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Dashboard retrieved
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingMyTeamDashboard"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`),
            or the caller is neither a people-manager nor a Training admin (`forbidden`).
  "/training/my_team/{id}":
    get:
      tags:
      - Training
      summary: One team member's training
      description: |
        A learner's drill-in: their four counts, everything outstanding, the
        completed history, and their issued certificates.

        Resolved THROUGH the caller's own team scope, so a manager cannot address
        someone else's report by id — a learner outside the caller's team is a
        404, the same answer a nonexistent id gets, so neither reveals the other.

        **`counts.assigned` vs `counts.in_progress` splits on PROGRESS, not
        status.** A learner who opened a course but completed nothing is
        `in_progress` to the model and "not started" to a manager reading a
        progress bar — this reports the latter, matching both the web page and the
        native design. `counts.overdue` counts outstanding rows past their due
        date.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The team member's user id, as returned in the dashboard's `members[].id`.
        schema:
          type: integer
          example: 41157
      responses:
        '200':
          description: Member retrieved
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingMyTeamMember"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the caller is not
            a team lead (`forbidden`).
        '404':
          description: No such user, or that person is not on the caller's team (error
            code `not_found` for both).
  "/training/my_team/{id}/remind":
    post:
      tags:
      - Training
      summary: Remind a team member about one course
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Nudge ONE enrollment. Per-enrollment rather than per-learner, because that
        is where the affordance sits on both surfaces — a Remind button on each
        outstanding row — and because the web door is per-enrollment too.

        **Which email is sent is decided server-side by what can honestly be
        said**: a course with a due date gets the due-date reminder ("due in N
        days"); everything else gets the general course reminder. The client does
        not choose, and there is one implementation behind all three doors (the
        two web ones and this) — they previously sent DIFFERENT emails under the
        same button name, which is why it was consolidated.

        Delivery is queued, so a 200 means "accepted and enqueued", not
        "delivered". `message` is the sentence to show the user and is the same
        one the web flashes.

        The enrollment must belong to the named team member; anything else is a
        404 rather than a silent no-op.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The team member's user id.
        schema:
          type: integer
          example: 41157
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - enrollment_id
              properties:
                enrollment_id:
                  type: integer
                  description: One of the member's own enrollment ids — from this
                    member's `open[].enrollment_id`, or from the dashboard's `overdue[].enrollment_id`.
                    Must belong to this member.
                  example: 22989
      responses:
        '200':
          description: Reminder enqueued
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  required:
                  - enrollment_id
                  - reminded
                  - message
                  properties:
                    enrollment_id:
                      type: integer
                      example: 22989
                    reminded:
                      type: boolean
                      example: true
                    message:
                      type: string
                      description: The sentence to surface to the user.
                      example: Reminder sent to Priya Raman.
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the caller is not
            a team lead (`forbidden`).
        '404':
          description: That person is not on the caller's team, or the enrollment
            is not theirs (error code `not_found` for both).
  "/training/courses/{course_id}/reviews/{id}":
    patch:
      tags:
      - Training
      summary: Edit your own course review
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Edit the caller's own review — the native mirror of the web "Edit your
        review" CTA on the ratings block.

        **Send only what changes.** `rating`, `title` and `content` are each
        applied only when the key is PRESENT, so a client can patch the star
        rating without resending prose it is not touching. Sending `title` or
        `content` blank is a real change (it clears the field); a blank `rating`
        fails its presence validation rather than silently keeping the old stars.
        Sending none of the three is 422 `nothing_to_update`.

        **AUTHOR ONLY**, with no time window — the web gate verbatim. A Training
        admin moderates reviews through the admin surface but may not rewrite
        words attributed to a learner, so an admin editing someone else's review
        is 403 `forbidden`.

        **Not re-run on edit**, both matching the web: the verified-purchase
        badge (an edit cannot change what was purchased) and `is_approved` — an
        edit does NOT send an approved review back to moderation, which would
        make it vanish from the list the client is displaying.

        The response carries the **recomputed** `rating` histogram, because
        editing the stars moves it. Payload is identical to the POST's
        (`TrainingReviewWriteResult`).
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: id
        in: path
        required: true
        description: The review's own id — as returned in `my_review.id` by the reviews
          list.
        schema:
          type: integer
          example: 110
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewUpdateRequest"
      responses:
        '200':
          description: Review updated
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`),
            or the review belongs to someone else (`forbidden`).
        '404':
          description: No such course visible to this caller, or no such review on
            it — including a review that belongs to another course or another tenant
            (error code `not_found` for all, so none reveals the others).
        '422':
          description: |
            `nothing_to_update` (no editable key sent) in the standard
            `{ error: { code, message } }` envelope, or a validation failure
            (out-of-range `rating`, over-long `title`/`content`) in the per-field
            `{ errors: [{ field, message, code }] }` shape.
  "/training/learning_paths/{learning_path_id}/reviews/{id}":
    patch:
      tags:
      - Training
      summary: Edit your own learning-path review
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Edit the caller's own review of a learning path. Identical body, payload
        and error codes to the course variant above — reviews are polymorphic, so
        one controller serves both (`subject.type` is `path`).
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 110
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewUpdateRequest"
      responses:
        '200':
          description: Review updated
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the review is someone
            else's (`forbidden`).
        '404':
          description: No such learning path visible to this caller, or no such review
            on it (`not_found`).
        '422':
          description: "`nothing_to_update`, or a validation failure — see the course
            variant above."
  "/training/courses/{course_id}/sessions":
    get:
      tags:
      - Training
      summary: Bookable sessions for an instructor-led course
      description: |
        The "choose your session" list — the native counterpart of My Learning →
        an ILT course → the session-list dialog.

        **Only bookable rows.** `scheduled` + starting in the future, ordered
        soonest-first. A past or cancelled session is omitted rather than returned
        with a closed CTA: you cannot register into either, and a list that led
        with last month's cohort is the wrong answer to "which session shall I
        take".

        **`cta_action` is the row's verb**, resolved server-side with the same
        precedence the web row uses, so no client re-derives it:
        `registered` · `waitlisted` (a seat you already hold — always wins) ·
        `closed` (registration window shut) · `join_waitlist` (full) ·
        `switch` (you hold a seat on a DIFFERENT session of this course) ·
        `register`.

        **`capacity` is computed once for the whole list** in a single grouped
        query, so a course with a weekly schedule costs no per-row counting.
        `taken` counts occupied seats (registered/attended/completed);
        `waitlist_count` is separate and does not consume capacity.

        **Render times in the SESSION's zone.** `starts_at` / `ends_at` are the
        instants in UTC; `timezone` / `timezone_label` are the zone to show them in,
        which is what the web renders — a class happens where it happens, not where
        the viewer is. The pre-converted `starts_at_local` / `ends_at_local` were
        removed on 2026-09-01 as derivable from those two.

        **`can_register` reflects BOTH switches** — the tenant-wide Training
        setting AND this course's own `allow_self_enrollment`. When it is false
        the rows are still returned (so the schedule can be displayed) but
        `POST /training/sessions/{id}/register` will answer 403; disable the
        button rather than letting the user discover it on tap.

        `my_registration` reports the caller's hold in WHATEVER state, including
        `attended`/`completed`, which the per-row `cta_action` deliberately
        ignores — see the note on the register endpoint.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
        description: The instructor-led course.
      responses:
        '200':
          description: The bookable session list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  course:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      delivery_mode:
                        type: string
                      instructor_led:
                        type: boolean
                  sessions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TrainingSessionRow"
                  my_registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  can_register:
                    type: boolean
                    description: Both self-enrollment switches are on.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`).
        '404':
          description: No such course, or it is not visible to this caller (`not_found`).
        '422':
          description: The course is not instructor-led, so it has no sessions (`not_instructor_led`).
  "/training/sessions/{id}":
    get:
      tags:
      - Training
      summary: One instructor-led session
      description: |
        A single session addressed by its own id — the same row shape the list
        returns, for a client that holds a session id (from a reminder, a deep
        link, or a previously fetched list) without its course.

        Flat rather than nested under the course for the same reason the `qa/`
        routes are: the id addresses the row on its own and the course is derived
        from it. Visibility is still enforced through the course — a session whose
        course has been unpublished or archived answers 404.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  course:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      delivery_mode:
                        type: string
                      instructor_led:
                        type: boolean
                  session:
                    "$ref": "#/components/schemas/TrainingSessionRow"
                  my_registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  can_register:
                    type: boolean
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`).
        '404':
          description: No such session in this business, or its course is no longer
            visible (`not_found`).
  "/training/sessions/{id}/register":
    post:
      tags:
      - Training
      summary: Take (or move) the caller's seat on a session
      description: |
        **Requires the `write:training` scope** (see the Training write-scope note
        on the review and Q&A write endpoints). No request body.

        **One endpoint covers register, switch AND join-waitlist.** The web has
        three actions; they differ only in the message they flash. Underneath,
        `Training::SessionRegistrationService#switch` is documented as the same
        operation as `#register` ("it replaces whatever active hold the learner
        has for the course"), and `#register` already falls back to the waitlist
        when a session is full. So: POST here to book a seat, to MOVE your seat
        from another session of the same course, or to queue for a full one.

        **`intent` says what you ASKED for**, which is a different question from
        what the seats allow. `register` (the default when the field is absent)
        takes a seat and falls back to the waitlist if the session is full;
        `waitlist` queues you deliberately even when a seat is free — the two
        buttons a session row draws ("Choose this session" / "Join waitlist").
        An unrecognised value is a 422 `invalid_intent`, NOT a silent fallback:
        guessing `register` for a mistyped `waitlist` would book a seat you never
        asked for. The response echoes `intent`, so `intent: register` alongside
        `status: waitlisted` is exactly how you detect that the last seat went
        between the list and the tap.

        **Read `status` from the response — do not predict it.** It is
        `registered` or `waitlisted`, decided under a row lock on the session, so
        a seat that filled between your GET and your POST cannot over-book. The
        returned `session` already reflects your write (its `capacity` and
        `cta_action` are re-read afterwards), so a client can re-render the row
        without a second request.

        **Idempotent.** Posting again for a session you already hold returns that
        same registration rather than creating a duplicate.

        **Registering is also how you ENROLL.** For an instructor-led course the
        service creates the `TrainingEnrollment` if you have none, applying the
        same prerequisite gating as catalog enrollment — a prerequisite failure
        comes back 422 with the service's own message.

        **Refusals**, each with a stable `error.code`:

        - `self_enrollment_disabled` (403) — either the tenant-wide Training
          setting or this course's `allow_self_enrollment` is off. `can_register`
          on the two GETs above reports the same thing in advance.
        - `registration_closed` (422) — the session's registration window has shut.
        - `already_attended` (422) — you have already sat a session for this
          course. Refused rather than re-booked: moving that hold would discard
          earned attendance credit. A re-assignment opens a new attempt.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                intent:
                  type: string
                  enum:
                  - register
                  - waitlist
                  default: register
                  description: What the caller is asking for. Omit for the historical
                    behaviour (take a seat, fall back to the waitlist when full).
      responses:
        '200':
          description: The seat, and the row re-read after the write.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                    - registered
                    - waitlisted
                    - attended
                    description: '`attended` is returned by the idempotent path only
                      — you already sat this session, so nothing was booked and your
                      seat is spent. Treat it as "no change", not as a new booking.'
                  waitlisted:
                    type: boolean
                  intent:
                    type: string
                    enum:
                    - register
                    - waitlist
                    description: The intent that was applied, echoed back.
                  message:
                    type: string
                    description: The same sentence the web flashes.
                  registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  session:
                    "$ref": "#/components/schemas/TrainingSessionRow"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), the token lacks `write:training`
            (`insufficient_permissions`), or self-enrollment is off for the tenant
            or the course (`self_enrollment_disabled`).
        '404':
          description: No such session in this business, or its course is no longer
            visible (`not_found`).
        '422':
          description: "`registration_closed`, `already_attended`, or a prerequisite
            failure from the enrollment step."
  "/training/sessions/{id}/registration":
    delete:
      tags:
      - Training
      summary: Give up the caller's seat, or leave the waitlist
      description: |
        **Requires the `write:training` scope.** No request body.

        The correction path for `POST /training/sessions/{id}/register` — a
        learner who booked a seat can release it, and one who joined a queue can
        leave it. This is the session row's "Leave waitlist" state, and the
        counterpart of the web's `DELETE .../cancel_registration`.

        **DELETE on the REGISTRATION sub-resource, not on the session** — the
        session is not being removed, the caller's hold on it is.

        **Cancelling is what frees a seat**, so the service promotes the head of
        that session's waitlist inside the same call. No client needs to do that
        arithmetic, and no second request is required.

        **An `attended` hold cannot be cancelled** — it answers 422
        `no_active_registration` and the record is left intact. Erasing it would
        discard the learner's earned attendance credit; the session list reports
        the seat as attended instead. Same rule the web states outright.

        Scoped to THIS session: a hold on a different session of the same course
        is not cancelled by this route.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The hold was cancelled, and the row re-read afterwards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cancelled:
                    type: boolean
                  message:
                    type: string
                  registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  session:
                    "$ref": "#/components/schemas/TrainingSessionRow"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '404':
          description: No such session in this business, or its course is no longer
            visible (`not_found`).
        '422':
          description: "`no_active_registration` — the caller holds nothing cancellable
            on this session (including the attended case above)."
  "/leader-rounds/due":
    get:
      tags:
      - Leader Rounds
      summary: Rounds due on the caller's direct reports
      description: |
        The caller's due list — every ACTIVE direct report, with how long since
        each was last rounded on and whether that is past the tenant's cadence.

        **Direct reports only, and all of them.** A skip-level round can be
        logged on anyone in the caller's subtree, but it carries no obligation —
        otherwise a director would be "overdue" on hundreds of people on day
        one. Terminated and suspended users never appear.

        **Leaders only, but never an error.** A non-leader gets
        `entries: []` and `due_count: 0`, so a client can call this
        unconditionally.

        Only SUBSTANTIVE completed rounds clear a row (a completed round with
        every answer blank scores nothing — guardrail #1 against rounding
        theatre), and a recorded skip suppresses the obligation without
        pretending the person was rounded on: their entry carries
        `due: false` with `never_rounded: true` and no `last_rounded_on`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Due list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - cadence_days
                - due_count
                - entries
                properties:
                  cadence_days:
                    type: integer
                    description: The tenant's rounding cadence in days (the `default_cadence_days`
                      app setting, clamped to 1..365). Published so a client need
                      not hardcode 30.
                    example: 30
                  due_count:
                    type: integer
                    description: 'How many entries carry `due: true`.'
                    example: 3
                  pacing:
                    type: object
                    description: This period's pacing plan for the caller — how many
                      rounds the cadence expects of them in the current window, how
                      many are done, and whether they are on pace. Emitted on every
                      response; previously undocumented.
                    properties:
                      window:
                        type: string
                        example: 2026-09
                      window_label:
                        type: string
                        example: September
                      target:
                        type: integer
                        example: 6
                      done:
                        type: integer
                        example: 2
                      remaining:
                        type: integer
                        example: 4
                      paced:
                        type: boolean
                        example: false
                  entries:
                    type: array
                    items:
                      type: object
                      required:
                      - subject_id
                      - subject_name
                      - due
                      - never_rounded
                      properties:
                        subject_id:
                          type: integer
                          example: 1752
                        subject_name:
                          type: string
                          example: Marcus Bell
                        last_rounded_on:
                          type: string
                          format: date
                          nullable: true
                          description: Null when never rounded.
                          example: '2026-07-12'
                        days_since:
                          type: integer
                          nullable: true
                          description: Null when never rounded.
                          example: 37
                        due:
                          type: boolean
                          description: Past cadence (or never rounded) AND not suppressed
                            by a recorded skip. This is the flag a client badges on
                            — but badge it in TEXT, not colour alone.
                          example: true
                        never_rounded:
                          type: boolean
                          example: false
                        last_skipped_on:
                          type: string
                          format: date
                          nullable: true
                          description: 'When a skip was last recorded for this person,
                            else null. WHY a row is not due is not derivable from
                            the other fields: a recorded skip suppresses the obligation
                            without counting as a round, so a skipped-and-never-rounded
                            subject reads `due: false` / `never_rounded: true` / `last_rounded_on:
                            null` — identical to someone whose cadence simply has
                            not lapsed. A client must render those two differently
                            ("Never rounded", no Due badge, because a skip is recorded).'
                          example: '2026-08-18'
                        job_title:
                          type: string
                          nullable: true
                          description: The subject's credential, null when the tenant
                            does not record one. How a person is identified on a floor
                            ("Marcus Bell, RN") — two Bells on one unit is normal,
                            so a bare name is genuinely ambiguous.
                          example: RN
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute URL, null when no avatar is set.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/…
                        scheduled:
                          type: boolean
                          description: True when a recurring rounding meeting carrying
                            a real calendar event exists for this leader/subject pair.
                            DISPLAY ONLY — it never influences due-ness (CalendarPairing
                            feeds nothing in DueList/CoverageReport), so a scheduled
                            round is still owed until it is logged.
                          example: true
                        scheduled_for:
                          type: string
                          format: date
                          nullable: true
                          description: The next non-cancelled occurrence, or null
                            when `scheduled` is true but the series has not materialised
                            that far ahead — a real state, not an error.
                          example: '2026-08-25'
                        scheduled_pairing_id:
                          type: integer
                          nullable: true
                          description: The CalendarPairing id, present only when `scheduled`
                            is true. Pass it to `DELETE /leader-rounds/calendar-pairings/{id}`
                            to remove the standing meeting. A client without this
                            id can only ever ADD a pairing, and the server then refuses
                            a second one with 422 `already_paired`.
                          example: 3
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Leader Rounds is not enabled for the business (`app_disabled`)
            or not accessible to the caller (`app_forbidden`).
  "/leader-rounds/templates":
    get:
      tags:
      - Leader Rounds
      summary: Rounding templates and their question sets
      description: |
        The capture form's question set — every ACTIVE template in the tenant
        with its ordered, typed questions.

        **Why this endpoint exists:** `POST /leader-rounds/rounds` takes
        `answers` keyed by question id, and without this there was no way to
        learn a question id, its prompt, its type, or which questions are
        required. The capture form is undrawable without it.

        **`field_type` is the field to render on.** It is the app's own
        `question_type` translated into the platform's shared form-field
        vocabulary, so a client drives its existing dynamic form renderer
        instead of carrying a private translation table:

        | `question_type`    | `field_type` | Control |
        |--------------------|--------------|---------|
        | `text`             | `textarea`   | Free text |
        | `scale`            | `rating`     | 1..5, bounds published per question |
        | `boolean`          | `radio`      | Yes / No |
        | `choice`           | `select`     | From `choices` |
        | `recognition_pick` | `lookup`     | Business-user picker, stores a user id |
        | `issue_capture`    | `textarea`   | The issue DESCRIPTION — see below |

        **`field_type` is not a complete rendering contract — `inputs` is.**
        Two question types need MORE THAN ONE control, and `field_type` names
        only the first:

        | `question_type`    | inputs |
        |--------------------|--------|
        | `recognition_pick` | `referenced_user_id` (lookup) + `value` (the citation text) |
        | `issue_capture`    | `description` + `assigned_to_id` + `due_date` + `priority` |

        Every question therefore carries an `inputs` array — one entry for a
        simple question, several for a composite — plus a `composite` boolean for
        clients that want to branch. Render every entry in `inputs` and a
        composite cannot be half-built.

        This is not hypothetical. Before `inputs` existed, the native capture
        form trusted `field_type: lookup` and drew a recognition_pick's person
        picker WITHOUT its citation field. `Answer#recognition_content` builds
        the recognition post body from that citation, so every recognition posted
        from mobile read "Recognized during a leader round on <date>." — a public
        compliment on a colleague's feed with no reason in it. Nothing in the
        payload revealed the missing half; it took someone comparing the mobile
        and web forms side by side.

        Each input names its own `submit_as` (`answers` or `issues`), because
        that is the other half easily got wrong: an `issue_capture`'s inputs post
        through the top-level `issues` hash, not `answers`, and produce a row on
        the stoplight ledger.

        `default_template_id` is the template to open on. It is the
        industry-NEUTRAL staff template, not the first row: ordering is by
        (round_type, name), so "first" would hand a first-time leader either New
        Hire 30-60-90 or — on a tenant with an industry pack installed — a
        variant whose required questions ask about patients and supplies.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Templates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - templates
                properties:
                  default_template_id:
                    type: integer
                    nullable: true
                    description: The template a client should open on. See above.
                    example: 1
                  templates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - round_type
                      - questions
                      properties:
                        id:
                          type: integer
                          example: 1
                        name:
                          type: string
                          example: Staff Rounding
                        description:
                          type: string
                          nullable: true
                        round_type:
                          type: string
                          enum:
                          - staff
                          - new_hire
                          - senior_leader
                          - custom
                          example: staff
                        industry:
                          type: string
                          nullable: true
                          description: Set on templates installed from an industry
                            pack; null on the industry-neutral starters.
                          example:
                        cadence_days:
                          type: integer
                          nullable: true
                          description: Null means no obligation (ad-hoc rounds only).
                          example: 30
                        questions:
                          type: array
                          items:
                            "$ref": "#/components/schemas/LeaderRoundsTemplateQuestion"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not enabled (`app_disabled`), not accessible (`app_forbidden`),
            or the caller is not a leader (`leader_access_required`). This is the
            CAPTURE FORM's question set, so it carries the same leader gate the web
            capture screen does — a non-leader gets no Leader Rounds tabs in the native
            shell and could not submit the form anyway.
        '422':
          description: No active rounding template in the tenant (`no_active_template`)
            — an admin must activate one. A client should surface this rather than
            opening an empty form.
  "/leader-rounds/rounds":
    get:
      tags:
      - Leader Rounds
      summary: List rounds
      description: |
        Paginated rounds, newest `occurred_on` first.

        **`scope` selects the read tier** — the two web rounds surfaces
        expressed as one param:
        * `mine` (DEFAULT) — rounds the caller LED. The default is fixed for
          backward compatibility: widening it would hand every existing client
          rows it never asked for and may assume cannot appear.
        * `visible` — the full read tier: led ∪ subtree ∪ about-you, and the
          whole tenant for app admins. Without this a DIRECTOR had no API path
          to the rounds their own leaders logged.

        `leader_id` narrows to one leader inside the `visible` tier and implies
        it. A leader id outside the tier is refused (`leader_not_visible`, 403)
        rather than ignored — a silently-dropped filter returns a list that is
        not what the client asked for while looking like it is.

        Rows are list-shaped: no answers, no issues. Use
        `GET /leader-rounds/rounds/{id}` for the full record.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        schema:
          type: string
          enum:
          - mine
          - visible
          default: mine
        description: Read tier. See above.
      - name: leader_id
        in: query
        required: false
        schema:
          type: integer
        description: Narrow to one leader; implies `scope=visible`.
      - name: window
        in: query
        required: false
        schema:
          type: integer
          minimum: 7
          maximum: 365
        description: 'Narrow to rounds that OCCURRED in the last N days, so a drill-down
          from a coverage number keeps describing the period that number described.
          A blank or out-of-range value means NO date narrowing rather than a default:
          this list is a history, not a coverage measurement, and "everything" is
          a legitimate answer. The value actually applied is echoed as `window_days`
          (null when unwindowed).'
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Rounds retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - rounds
                - meta
                properties:
                  rounds:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                  window_days:
                    type: integer
                    nullable: true
                    description: The `window` value actually applied, echoed so a
                      client can tell an out-of-range request from an honoured one.
                      Null when no window narrowing was applied. The `window` parameter
                      description above has always promised this field; it was missing
                      from the schema.
                    example: 30
                  meta:
                    "$ref": "#/components/schemas/LeaderRoundsPagination"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: "`scope` outside the enum (`invalid_scope`). Note that an explicit
            `scope` is validated BEFORE `leader_id` upgrades it, so `?scope=bogus&leader_id=5`
            is still a 400."
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible; `leader_id` outside the caller's read tier
            (`leader_not_visible`); or `scope=visible` requested by a non-leader (`leader_access_required`).
            `scope=visible` is the Rounds Log tier, and its web twin gates the whole
            controller on leader access; a non-leader's own rounds are served by `GET
            /leader-rounds/my`, on the subject tier. `scope=mine` stays open to every
            caller.
    post:
      tags:
      - Leader Rounds
      summary: Capture a round
      description: |
        Record a rounding conversation. Delegates to the SAME service the web
        capture form uses (`LeaderRounds::RoundCreator`), so every gate holds
        identically on both doors: the subject must be inside the caller's
        subtree, a self-round is rejected (coverage would be a
        self-attestation), `occurred_on` cannot be in the future, required
        questions must be answered, and an `issue_capture` answer opens a
        tracked row on the stoplight ledger with an owner and a due date.

        **`answers` is keyed by template question id** — get the ids from
        `GET /leader-rounds/templates`. **`issues` is keyed the same way**, on
        the `issue_capture` question that raised each one.

        Saving may also post a recognition to the picked colleague's feed. That
        post is recorded against the answer, so a re-save cannot spam them.

        **Send an idempotency key.** `round.idempotency_key` is a
        client-generated string, unique per capture attempt. A replay of the
        same key returns the ORIGINAL round with **200** and `replayed: true`
        (not 201), so a client that retries a timed-out create converges
        instead of double-posting. This matters more than a duplicate row:
        saving a round also posts a recognition to a colleague's feed and opens
        a `Capa::Action` on the stoplight ledger, so one flaky upload would
        otherwise become a duplicate round, a spammed recipient and a phantom
        issue. Scoped to (business, leader) and enforced by a partial unique
        index, so two concurrent retries of one key still yield one round.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - round
              properties:
                round:
                  type: object
                  required:
                  - subject_id
                  - template_id
                  - occurred_on
                  properties:
                    subject_id:
                      type: integer
                      description: Must be inside the caller's reporting subtree,
                        and not the caller.
                      example: 1752
                    template_id:
                      type: integer
                      example: 1
                    occurred_on:
                      type: string
                      format: date
                      description: The date the conversation happened. Never in the
                        future.
                      example: '2026-08-18'
                    duration_minutes:
                      type: integer
                      minimum: 1
                      maximum: 480
                      nullable: true
                      example: 14
                    private_notes:
                      type: string
                      nullable: true
                      description: Leader tier only — never returned to the subject.
                        See the visibility note at the top of this file.
                    idempotency_key:
                      type: string
                      nullable: true
                      maxLength: 255
                      description: 'Client-generated, unique per capture attempt.
                        Replaying the same key returns the original round with 200
                        and `replayed: true` instead of creating a second one. Strongly
                        recommended on mobile.'
                      example: a3f1c2e0-9b17-4d55-b0e2-6c8a1f2d4e77
                answers:
                  type: object
                  description: Map of template question id → answer. Populate the
                    field matching the question's type; `value` is accepted for the
                    simple types.
                  additionalProperties:
                    type: object
                    properties:
                      value:
                        description: Scalar answer — string for text/choice, 1..5
                          integer for scale, boolean for yes/no.
                        oneOf:
                        - type: string
                        - type: number
                        - type: boolean
                      referenced_user_id:
                        type: integer
                        description: For a `recognition_pick` question — the colleague
                          picked.
                  example:
                    '2':
                      value: Coverage has been steady since the swap flow shipped.
                    '5':
                      value: 4
                issues:
                  type: object
                  description: Map of `issue_capture` question id → the tracked item
                    to open on the stoplight ledger.
                  additionalProperties:
                    type: object
                    required:
                    - description
                    properties:
                      description:
                        type: string
                      assigned_to_id:
                        type: integer
                        nullable: true
                        description: The owner. Never inferred by AI — always an explicit
                          pick.
                      due_date:
                        type: string
                        format: date
                        nullable: true
                      priority:
                        type: string
                        nullable: true
                        enum:
                        - low
                        - medium
                        - high
                        - critical
      responses:
        '201':
          description: Round captured
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                    - type: object
                      properties:
                        answers_count:
                          type: integer
                          example: 5
                        issues_count:
                          type: integer
                          example: 1
                  warnings:
                    type: array
                    items:
                      type: string
                    description: The round SAVED, but part of the submission did not
                      land as sent — e.g. a recognition pick who is no longer active
                      (no recognition recorded), an issue owner who no longer resolves
                      (the issue falls back to the leader and, because a self-assignment
                      is not notified, nobody is told at all), issues past the 10-per-question
                      ceiling, or a due date that was clamped. None of this is visible
                      in the counts, which all look right — so an empty array is the
                      only "everything landed as sent" signal, and a client should
                      surface any entry to the leader rather than treating the 201/200
                      as the whole answer.
                    example: []
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '200':
          description: Idempotent replay. The `round.idempotency_key` you sent has
            already been used by this leader, so the ORIGINAL round is returned and
            nothing new was created. `replayed` is true. Treat this as success.
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                    - type: object
                      properties:
                        answers_count:
                          type: integer
                          example: 5
                        issues_count:
                          type: integer
                          example: 1
                  replayed:
                    type: boolean
                    example: true
                  warnings:
                    type: array
                    items:
                      type: string
                    description: The round SAVED, but part of the submission did not
                      land as sent — e.g. a recognition pick who is no longer active
                      (no recognition recorded), an issue owner who no longer resolves
                      (the issue falls back to the leader and, because a self-assignment
                      is not notified, nobody is told at all), issues past the 10-per-question
                      ceiling, or a due date that was clamped. None of this is visible
                      in the counts, which all look right — so an empty array is the
                      only "everything landed as sent" signal, and a client should
                      surface any entry to the leader rather than treating the 201/200
                      as the whole answer.
                    example: []
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '422':
          description: Validation failed — subject outside the caller's subtree, a
            self-round, a future `occurred_on`, a missing required answer, or a scale
            value outside 1..5. The error code names which.
  "/leader-rounds/rounds/{id}":
    get:
      tags:
      - Leader Rounds
      summary: One round in full
      description: |
        The whole record of one conversation: every answer in template order,
        the issues it raised, the recognition it posted, and — for the leader
        tier only — the private notes.

        **The tier is the server's job.** Whether `private_notes` is in the
        payload AT ALL is decided here, not by the client hiding a section:
        on a subject's response the key is ABSENT. `private_notes_visible`
        states it positively so a client can DRAW the absence ("your leader's
        private notes aren't shown here") rather than leaving a silent gap.

        `private_notes_visible` is true ONLY for the round's own leader — not
        for a director above them or an app admin, both of whom can read the
        round itself. It tracks whether `private_notes` is in THIS response, not
        whether the caller is inside the notes tier: a flag that said "visible"
        while the key was withheld made an empty card read as "the leader wrote
        none", which is a different claim from "not shown to you".
        Everything else — the same answers, the same issue rows — is identical
        between the two tiers, because it is literally the same record.

        A round id outside the caller's read tier and a nonexistent id both
        return 404 with the same body. See the enumeration note at the top.

        Note an `issue_capture` question produces a LEDGER ROW, not an answer
        row, so `answers` can be shorter than the template's question count
        while `issues` carries the difference.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Round retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                    - type: object
                      required:
                      - answers
                      - issues
                      - private_notes_visible
                      properties:
                        duration_minutes:
                          type: integer
                          nullable: true
                          example: 14
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                        answers_count:
                          type: integer
                          example: 5
                        issues_count:
                          type: integer
                          example: 1
                        private_notes_visible:
                          type: boolean
                          description: False on the subject tier, where `private_notes`
                            is absent from the payload entirely.
                          example: true
                        answers:
                          type: array
                          description: Ordered by the template's own question order.
                          items:
                            "$ref": "#/components/schemas/LeaderRoundsAnswer"
                        issues:
                          type: array
                          description: The stoplight rows this round raised, oldest
                            first.
                          items:
                            "$ref": "#/components/schemas/LeaderRoundsIssue"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Round not found (`not_found`) — returned identically for a
            nonexistent id and for one outside the caller's read tier.
  "/leader-rounds/team":
    get:
      tags:
      - Leader Rounds
      summary: Coverage rollup up the reporting line
      description: |
        The coverage report with layer-by-layer drill-down. **The axis is the
        reporting line, not the location tree.** Every aggregate is batched — no
        per-leader queries.

        Three collections answer three different questions, and mixing them up
        is the usual misreading:
        * `summary` — the totals for the anchored node, including its own direct
          reports.
        * `units` — the layer DIRECTLY BELOW the node, each row aggregating that
          unit's whole subtree. The node's own direct reports sit in `summary`
          and in no unit row; without knowing that, a reader finds a gap between
          the two and concludes one is broken.
        * `leaders` — flat per-leader rows for the subtree, paginated. A
          leader's row counts THEIR OWN direct reports only.

        Ordered worst-first (`[-red_issues, coverage_pct]`) so the response opens
        on the problem.

        **Coverage counts SUBSTANTIVE completed rounds only** — blank-answer
        rounds score nothing (guardrail #1). `same_day_cluster` is guardrail #2:
        true when five or more rounds in the window have 80% of them stamped on
        one calendar day. Nine rounds inside twenty-two minutes is a signal a
        director should see, not something to smooth away.

        Ageing is reported as two separate numbers because they answer different
        questions: `oldest_red_days` is days PAST DUE (the same clock the
        escalation job counts on), `oldest_open_days` is days SINCE RAISED.
      security:
      - BearerAuth: []
      parameters:
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: Anchor at one leader in the caller's tree. Blank means no narrowing.
          An id outside the caller's tree is refused (`node_not_visible`, 403) rather
          than clamped — on an API a silently-ignored filter produces a mislabelled
          list.
      - name: window
        in: query
        required: false
        schema:
          type: integer
          default: 30
          minimum: 7
          maximum: 365
        description: Measurement window in days. Out-of-range values fall back to
          30.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Rollup retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - window_days
                - breadcrumb
                - summary
                - units
                - leaders
                - meta
                properties:
                  window_days:
                    type: integer
                    example: 30
                  node:
                    type: integer
                    nullable: true
                    description: The anchored node, or null for the caller's own root.
                  breadcrumb:
                    type: array
                    description: Path from the top of the caller's span down to `node`.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  summary:
                    type: object
                    description: Totals for the anchored node. Includes its own direct
                      reports.
                  at_span_roots:
                    type: boolean
                    description: 'TRUE only for an app admin on the UNNAVIGATED root,
                      where `units` is not an org layer but the UNPARENTED bag — every
                      leader whose manager is unset or is not themselves a leader.
                      It cannot be inferred from `node == null`: a non-admin leader
                      anchored on their own node also has a null node, and for them
                      the units genuinely ARE their reports. A client can use it to
                      explain a long flat list (69 rows on the dev tenant, where 61,122
                      of 62,107 members have no manager set) instead of letting a
                      director read it as their org chart.'
                  units:
                    type: array
                    description: The layer directly below the node; each row aggregates
                      its whole subtree.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          example: Priya Raman · 4 East
                        leaf:
                          type: boolean
                          description: No layer below. A leaf row should open the
                            leader detail rather than descending into an empty level.
                        leaders:
                          type: integer
                        team_size:
                          type: integer
                        rounded_subjects:
                          type: integer
                        coverage_pct:
                          type: number
                          format: float
                        open_issues:
                          type: integer
                        red_issues:
                          type: integer
                        coverage_delegated_out:
                          type: boolean
                          description: 'TRUE when EVERY leader rolled up under this
                            unit has handed their rounding to someone above them for
                            this window — obligation counts under the delegate, and
                            the row survives only to keep the unit''s open ledger
                            visible. It is NOT derivable from the payload: `coverage_pct`
                            is a literal 0 and `expected` a literal 0 in this state,
                            exactly as they are when the unit simply had nobody due,
                            so a client that renders the two the same way accuses
                            a delegated unit of doing nothing. Render the caption,
                            not the bar — "Rounding delegated for this window; open
                            issues stay theirs" versus "No rounds expected in this
                            window".'
                        avatar_url:
                          type: string
                          nullable: true
                          description: The unit's own leader — a unit node IS a person.
                            Null when they are no longer an active member; the row
                            still belongs in the rollup (their team's coverage is
                            real), so a client falls back to initials.
                  leaders:
                    type: array
                    description: Per-leader rows for the subtree, worst first, paginated.
                    items:
                      type: object
                      properties:
                        leader_id:
                          type: integer
                        leader_name:
                          type: string
                        leader_job_title:
                          type: string
                          nullable: true
                          description: The credential, null when the tenant records
                            none.
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute URL, null when no avatar is set.
                        credit_mentions:
                          type: integer
                          description: Credit RAISED in this leader's rounds — a recognition_pick
                            answer naming someone.
                        credit_posted:
                          type: integer
                          description: How much of `credit_mentions` actually reached
                            Recognitions. A gap means praise is dying in the form,
                            which is why both numbers are published rather than a
                            ratio.
                        team_size:
                          type: integer
                        rounded_subjects:
                          type: integer
                        coverage_pct:
                          type: number
                          format: float
                        open_issues:
                          type: integer
                        red_issues:
                          type: integer
                        coverage_delegated_out:
                          type: boolean
                          description: 'Same flag as on a unit row, for one leader:
                            their rounding is with someone above them for this window.
                            Their team''s obligation counts under the delegate''s
                            row; this row survives so their standing reds do not vanish
                            from the rollup. Not derivable — see the unit row''s note.'
                        oldest_open_days:
                          type: integer
                          nullable: true
                          description: Days since raised.
                        oldest_red_days:
                          type: integer
                          nullable: true
                          description: Days PAST DUE.
                        same_day_cluster:
                          type: boolean
                          description: Guardrail
                  meta:
                    "$ref": "#/components/schemas/LeaderRoundsPagination"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or `node` outside the caller's tree (`node_not_visible`).
  "/leader-rounds/issues":
    get:
      tags:
      - Leader Rounds
      summary: The stoplight issue ledger
      description: |
        Issues raised in rounds inside the caller's READ tier, with their
        derived stoplight colour.

        **Colour is DERIVED, never stored:** green is completed, red is
        cancelled or open-past-due, yellow is the open remainder. A cancelled
        issue shares red's colour but is a decision, not limbo — clients should
        label it "Won't fix" rather than repeating "Red".

        `color` is validated against the enum case-insensitively; anything else
        is an explicit 400. A wrong-case or off-enum value must never return
        every colour while the client believes the list is filtered.

        `node` narrows to one drill-down unit's subtree, composing INSIDE the
        read tier — it can only remove rows, never widen the tier. It is the
        same param the rollup uses, so one applied scope reads identically on
        both surfaces.

        **`tally` counts the WHOLE scope, before `color` is applied** — the web
        ledger's `Stoplight.sql_tally`, as three COUNTs. A paginated client must
        render its colour pills from this and never from the rows it holds:
        counting the fetched array caps every total at `per_page` and drops
        colours that sort past the first page (open-first ordering puts a closed
        won't-fix last, so reds are exactly what goes missing). It is also what
        makes `color` safe to send — the pills keep describing the ledger while
        the list is a filtered, paginated query. `meta.total` is the size of the
        FILTERED query; `tally.total` is the size of the scope.
      security:
      - BearerAuth: []
      parameters:
      - name: color
        in: query
        required: false
        schema:
          type: string
          enum:
          - red
          - yellow
          - green
        description: Case-insensitive. Off-enum values are a 400, not an unfiltered
          list.
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: Narrow to one unit's subtree, inside the read tier.
      - name: leader_id
        in: query
        required: false
        schema:
          type: integer
        description: Narrow to ONE leader's rounds — what a per-leader issue count
          on the rollup links to, so the number and the list it opens describe the
          same set. Composes on top of the read tier (it can only remove rows). An
          out-of-tier or nonexistent id is refused IDENTICALLY (403 `leader_not_visible`),
          because a distinguishable refusal is a leader enumerator. `tally` reflects
          this narrowing.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Ledger retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - issues
                - tally
                - meta
                properties:
                  issues:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsIssue"
                  tally:
                    type: object
                    description: Stoplight counts over the whole scope, independent
                      of `color` and of pagination. `total` is their sum — the colours
                      are exhaustive and disjoint over the status set, so a separate
                      COUNT could only disagree with them.
                    required:
                    - red
                    - yellow
                    - green
                    - total
                    properties:
                      red:
                        type: integer
                        example: 1
                      yellow:
                        type: integer
                        example: 7
                      green:
                        type: integer
                        example: 4
                      total:
                        type: integer
                        example: 12
                  meta:
                    "$ref": "#/components/schemas/LeaderRoundsPagination"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: "`color` outside the enum (`invalid_color`)."
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, `node` not visible (`node_not_visible`),
            or `leader_id` outside the caller's read tier (`leader_not_visible`).
  "/leader-rounds/issues/{id}":
    get:
      tags:
      - Leader Rounds
      summary: One issue, with its transition history
      description: |
        The single-issue read that makes an issue DETAIL possible.

        `history` is deliberately absent from the list endpoints — it is a query
        per row — so a detail screen rendered from a list row could never show
        the trail. It existed on exactly one response (the status change), which
        meant the history was visible only in the seconds after the caller
        themselves changed something.

        It arrives as an ENVELOPE key (`history`), a sibling of `issue`, NOT
        nested inside it — the same shape the status write emits. A client that
        reads it off the issue object finds nothing and renders an empty trail
        with no error.

        **The read tier here is WIDER than the ledger's, deliberately.** An issue
        ASSIGNED to the caller from a round they cannot see is precisely the case
        the "Assigned to you" surface exists for — a non-leader has no visible
        rounds at all, so scoping this to visible rounds alone would 404 the one
        person the screen serves. Readable = from a round the caller can see, OR
        assigned to the caller. Neither grants a WRITE: that stays the manageable
        set, and `read_only` on the response states which side the caller is on.

        A nonexistent id and an out-of-tier id are refused IDENTICALLY (404
        `not_found`), because a distinguishable refusal is an issue enumerator.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The issue plus its `history`; the issue carries `read_only`
            and `round_visible`
          content:
            application/json:
              schema:
                type: object
                required:
                - issue
                - history
                properties:
                  issue:
                    "$ref": "#/components/schemas/LeaderRoundsIssue"
                  history:
                    "$ref": "#/components/schemas/LeaderRoundsIssueHistory"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not visible to the caller, or no such issue — the two are indistinguishable
            by design (`not_found`).
    patch:
      tags:
      - Leader Rounds
      summary: Reassign an issue, or move its due date / priority
      description: |
        The issue's OWNER, DUE DATE and PRIORITY — everything about an issue
        except its status, which has its own endpoint because only that one
        carries the won't-fix reason gate.

        Delegates to `LeaderRounds::IssueDetailsUpdater`, the same service the
        web panel uses, so three rules hold on both doors: a due date is
        validated (not silently clamped, unlike at creation), the audit note and
        the field change are written in one transaction, and a request that
        changes NOTHING is not an event — it returns `changed: false` rather
        than logging a note saying nothing happened and notifying the "new"
        owner who is the old owner.

        **WRITE TIER.** Being the issue's owner does not let you reassign it;
        being the round's subject does not either. Outside the tier is 404,
        identical to a nonexistent id.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Send only what is changing — an omitted field is left alone.
                TOP-LEVEL keys; unlike the web form, this endpoint does not accept
                them wrapped in `issue`.
              properties:
                assigned_to_id:
                  type: integer
                  description: The new owner. Must be an active member of the business.
                due_date:
                  type: string
                  format: date
                  nullable: true
                priority:
                  type: string
                  enum:
                  - low
                  - medium
                  - high
                  - critical
                pillar_id:
                  type: string
                  nullable: true
                  description: 'Recategorize the issue under one of the tenant''s
                    pillars, overriding the pillar derived from the question that
                    raised it. Three values matter: a pillar id PINS that pillar;
                    the sentinel `"0"` pins **Unaligned** (an explicit "this belongs
                    to no pillar", which outranks the question''s own mapping); an
                    empty string CLEARS the override so the issue falls back to whatever
                    the raising question derives. An id belonging to another tenant
                    is refused, not ignored. Read the result back from the response''s
                    `pillar_id` / `pillar_name`.'
                  example: '4'
      responses:
        '200':
          description: Applied. `changed` is false when the payload matched what was
            already stored — no audit note was written and nobody was notified. The
            issue carries `read_only` and `round_visible`, because a detail screen
            folds this reply back into the record it is displaying.
          content:
            application/json:
              schema:
                type: object
                required:
                - issue
                - changed
                properties:
                  issue:
                    "$ref": "#/components/schemas/LeaderRoundsIssue"
                  changed:
                    type: boolean
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not on the caller's manageable ledger, or no such issue.
        '422':
          description: An unusable owner, date or priority (`invalid`), with the reason
            in `error.message`.
  "/leader-rounds/issues/{id}/comments":
    post:
      tags:
      - Leader Rounds
      summary: Post a progress note on an issue
      description: |
        A note WITHOUT a status change — the thread on an issue.

        **READ tier, deliberately.** The person who raised an issue must be able
        to answer on it and so must the owner it was handed to; a thread only
        the ledger's leader can write to is a broadcast, not a conversation. The
        status itself still moves only on the write tier.

        The other party is notified (a leader's note reaches the raiser, a
        reply reaches the owner), never the author, through the same deduped
        in-app "leader_rounds" category as every other notification this app
        sends. A note that landed is never reported as a failure because the
        notify path raised.

        Bodies are truncated at 5,000 characters. @mentions of business members
        are resolved.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  description: The note. Whitespace-only is rejected.
      responses:
        '200':
          description: The stored note
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    type: object
                    properties:
                      id:
                        type: integer
                      author:
                        type: string
                      author_avatar_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                      created_at:
                        type: string
                        format: date-time
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not readable by the caller, or no such issue.
        '422':
          description: A blank body, or the note could not be stored (`invalid`).
  "/leader-rounds/issues/{id}/status":
    patch:
      tags:
      - Leader Rounds
      summary: Update an issue's status
      description: |
        Move an issue on the stoplight ledger. Delegates to the same service the
        web ledger uses (`LeaderRounds::IssueStatusUpdater`), so the guardrails
        hold identically:

        * **Cancelling requires a reason, and the reason is PUBLISHED to the
          person who raised it.** This is what keeps the ledger honest — a
          leader may say no, but not silently. A cancel without
          `resolution_notes` is 422 `reason_required`.
        * A closed issue stays reopenable, so a misclicked "resolved" is
          recoverable.
        * The transition writes an audit note atomically with the status.

        **WRITE TIER, not read tier.** Being the SUBJECT of the round an issue
        came from grants read, never write; likewise being the issue's OWNER
        does not grant a status write — the status belongs to the leader whose
        ledger it is, so the person who raised it always hears back from their
        own leader. An issue outside the write tier is 404, identical to a
        nonexistent one.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  description: The target status on the CAPA register. Validated against
                    `Capa::Action::VALID_STATUSES` — anything else is a 400.
                  enum:
                  - pending
                  - in_progress
                  - completed
                  - cancelled
                  example: completed
                resolution_notes:
                  type: string
                  nullable: true
                  description: REQUIRED when cancelling. Published to the raiser —
                    write it for them to read, not for the audit log.
      responses:
        '200':
          description: Status updated; the reply carries the refreshed `history`,
            and the issue carries `read_only` and `round_visible` — a detail screen
            folds this reply back into the record it is displaying, so a reply that
            omitted them made the status control and the link to the round disappear
            the instant the write succeeded.
          content:
            application/json:
              schema:
                type: object
                required:
                - issue
                - history
                properties:
                  issue:
                    "$ref": "#/components/schemas/LeaderRoundsIssue"
                  history:
                    "$ref": "#/components/schemas/LeaderRoundsIssueHistory"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: An unusable target status.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Issue not on the caller's manageable ledger — returned identically
            for a nonexistent id and one outside the write tier.
        '422':
          description: Cancelling without `resolution_notes` (`reason_required`).
  "/leader-rounds/my":
    get:
      tags:
      - Leader Rounds
      summary: Rounds about me, what I raised, and what I own
      description: |
        The subject tier — the close-the-loop surface, and the answer to "what
        happened to what I said". Three collections, in the order the web
        renders them:

        * `assigned_issues` — work handed to you from someone else's round.
          **READ-ONLY by design**, flagged `read_only: true` on every row: the
          status belongs to the leader whose ledger it is. Each row names who
          RAISED it, which is the one thing a bare ledger row cannot tell an
          owner. This is the entire issue-owner persona: at rollout a tenant
          enables Leader Rounds for nursing first, and support departments own
          issues long before anyone rounds on them — so an owner commonly has
          three assigned issues and two empty sections.
        * `issues` — the issues YOU raised, with their current colour. This
          visibility is the whole point: an issue ledger the workforce cannot
          see is a notebook, not a rounding program.
        * `rounds` — the completed rounds your leader logged with you. Subject
          tier, so `private_notes` is absent from every row.

        **Empty sections are answered honestly.** A department that has not
        adopted rounding gets genuinely empty `rounds` and `issues`; clients
        should say so plainly rather than painting a green tick.

        **Three collections means three cursors.** `page` walks `rounds`,
        `issues_page` walks `issues`, `assigned_page` walks `assigned_issues`,
        and each carries its own meta — so paging one never disturbs the others.
        `meta` stays bound to `rounds` (the envelope-wide key every other action
        here emits); `issues_meta` and `assigned_issues_meta` are its twins. All
        three honour `per_page`.

        **`assigned_issues` holds work handed to the caller from rounds they were
        NOT the subject of.** Rounds about the caller are excluded because those
        issues already appear in `issues` — listing them in both reads as two
        separate items. Each row carries `read_only`, computed per caller: false
        when the caller leads (or supervises the leader of) the round it came
        from, in which case the honest UI points them at the ledger rather than
        claiming somebody else owns it.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
        description: Cursor for `rounds`.
      - name: issues_page
        in: query
        required: false
        schema:
          type: integer
          default: 1
        description: Cursor for `issues` (what you raised).
      - name: assigned_page
        in: query
        required: false
        schema:
          type: integer
          default: 1
        description: Cursor for `assigned_issues` (what you own).
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
        description: Shared by all three collections.
      responses:
        '200':
          description: Subject-tier view retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - rounds
                - issues
                - assigned_issues
                - meta
                - issues_meta
                - assigned_issues_meta
                properties:
                  rounds:
                    type: array
                    description: Completed rounds about the caller. Subject tier —
                      no private notes.
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                  issues:
                    type: array
                    description: Issues the caller raised.
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsIssue"
                  issues_tally:
                    type: object
                    description: The stoplight split over everything the caller has
                      RAISED, counted over the WHOLE set rather than the page — a
                      client that counted `issues` would describe 25 rows, not the
                      ledger. Same contract as `tally` on /leader-rounds/issues.
                    required:
                    - red
                    - yellow
                    - green
                    - total
                    properties:
                      red:
                        type: integer
                      yellow:
                        type: integer
                      green:
                        type: integer
                      total:
                        type: integer
                  assigned_issues:
                    type: array
                    description: Issues assigned to the caller. Read-only.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaderRoundsIssue"
                      - type: object
                        properties:
                          raised_by_name:
                            type: string
                            nullable: true
                            description: The subject of the round this issue came
                              from — who asked for it.
                            example: Marcus Bell
                          read_only:
                            type: boolean
                            description: Always true. A client must not render a status
                              control here; the write tier excludes assignment.
                            example: true
                  meta:
                    "$ref": "#/components/schemas/LeaderRoundsPagination"
                  issues_meta:
                    "$ref": "#/components/schemas/LeaderRoundsPagination"
                  assigned_issues_meta:
                    "$ref": "#/components/schemas/LeaderRoundsPagination"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
  "/leader-rounds/rounds/skip":
    post:
      tags:
      - Leader Rounds
      summary: Record a skip
      description: |
        Record an untaken round as a **deliberate decision** rather than a gap
        ("subject on leave"). Delegates to `LeaderRounds::RoundSkipper`, the same
        service the web's skip action calls.

        A skip **suppresses the obligation without pretending the person was
        rounded on**: the row stops being due, while `/due` still reports
        `never_rounded: true` with no `last_rounded_on` and a populated
        `last_skipped_on`. The skip itself, with its reason, surfaces in the
        leader's recent-rounds history.

        **The reason is required** — a skip with no reason is a gap, not a
        decision, and the subject can read it. The subject must be inside the
        caller's reporting subtree (the same scope the capture picker uses), so
        a skip can never be filed against someone the create gate would reject.

        The template recorded against the skip is the industry-NEUTRAL staff
        template, never the ordering-first row: a skip filed against
        "New Hire 30-60-90" reads on the history row as a new-hire round for a
        ten-year veteran.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subject_id
              - skipped_reason
              properties:
                subject_id:
                  type: integer
                  description: Must be inside the caller's reporting subtree.
                  example: 1752
                skipped_reason:
                  type: string
                  description: Required. Recorded, and readable by the subject.
                  example: On leave until September.
      responses:
        '201':
          description: Skip recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or the subject is outside the caller's
            subtree (`invalid_subject`).
        '422':
          description: Missing reason (`reason_required`), no active template (`no_active_template`),
            or a model validation failure (`invalid`).
  "/leader-rounds/rounds/{id}/recognize":
    post:
      tags:
      - Leader Rounds
      summary: Post a round's recognition to Recognitions
      description: |
        Publish the round's `recognition_pick` answer to the named colleague's
        feed. Delegates to `LeaderRounds::RecognitionPoster`, shared with the
        web, which routes through `Recognition::PeerPostCreator` — the canonical
        entrypoint carrying every give-gate (peer access, governance,
        moderation, approval routing). Never posts to a feed directly.

        **ONE-SHOT BY CONSTRUCTION.** An already-posted answer returns **200
        with `already_posted: true`**, not an error — the whole point of the
        dedupe state is that a retry converges instead of spamming the
        recipient. `approval_status` is `pending_approval` / `pending_review`
        when the tenant's governance routes the post for review.

        **Only the leader who held the round may post its recognition** — the
        post carries their name, so a manager in the chain posting on their
        behalf would misattribute the credit.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - answer_id
              properties:
                answer_id:
                  type: integer
                  description: The round's `recognition_pick` answer.
                  example: 18
      responses:
        '200':
          description: Posted, or already posted
          content:
            application/json:
              schema:
                type: object
                required:
                - already_posted
                - answer
                properties:
                  already_posted:
                    type: boolean
                    description: True when a prior post already exists — not an error.
                    example: false
                  approval_status:
                    type: string
                    nullable: true
                    description: Set when tenant governance routes the post for review.
                    example: pending_review
                  recipient_name:
                    type: string
                    nullable: true
                    example: Amy Okonkwo
                  warnings:
                    type: array
                    items:
                      type: string
                    description: The give SUCCEEDED but something around it did not
                      — most importantly the one-shot marker may not have been written,
                      so a client that retries on an empty `warnings` list would double-post
                      to the recipient's feed. Treat a non-empty array as "posted,
                      do not retry, tell the leader".
                    example: []
                  answer:
                    "$ref": "#/components/schemas/LeaderRoundsAnswer"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not the round's leader (`not_round_leader`), or app not accessible.
        '404':
          description: Round not found or outside the caller's read tier (identical
            bodies).
        '422':
          description: The `answer_id` is not part of this round (`answer_not_found`
            — note this takes the ANSWER's `id`, not its `question_id`), the question
            does not collect a recognition (`not_a_recognition_answer`), the answer
            names nobody (`no_recognition_recipient`), or the recognition could not
            be posted (`recognition_failed`).
  "/leader-rounds/rounds/subject-search":
    get:
      tags:
      - Leader Rounds
      summary: Search the people the caller may round on
      description: |
        The capture form's person typeahead, and the picker behind "round on
        someone outside my due list".

        **SCOPE IS THE AUTHORIZATION.** Results are the caller's reporting
        subtree ∩ active members — the same scope `RoundCreator` and
        `RoundSkipper` resolve against, so the picker and the create gate cannot
        disagree. A global user search would offer people the gate then rejects,
        which reads to the user as the app losing their round.

        A blank `q` returns the first page of that scope, so the picker can open
        populated rather than empty. Page with `page` until `meta.more` is
        false — a subtree larger than `per_page` is otherwise unreachable.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: 'Case-insensitive substring match against first name, last name,
          the two joined as "First Last", display name, preferred name, email and
          job title. Because job title is matched, a query like `RN` returns everyone
          holding that title, not only people whose name contains it. The joined form
          is one-directional: `Dana Whitfield` matches, `Whitfield Dana` does not.'
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
          minimum: 1
        description: 1-based page cursor. Results are ordered by first name, last
          name, id.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
        description: Page size. A blank, zero, negative or non-numeric value falls
          back to the default rather than to the minimum.
      responses:
        '200':
          description: Candidate subjects
          content:
            application/json:
              schema:
                type: object
                required:
                - subjects
                properties:
                  subjects:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 1752
                        name:
                          type: string
                          example: Marcus Bell
                        email:
                          type: string
                          example: marcus@example.com
                        job_title:
                          type: string
                          nullable: true
                          description: Null when the tenant records none. Present
                            so a round opened from this picker identifies its subject
                            the same way one opened from /due does.
                          example: RN
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute URL, null when no avatar is set.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/…
                  meta:
                    type: object
                    description: Cursor state. `more` is true when this page came
                      back full, i.e. another page is plausibly available. There is
                      deliberately no total — counting the whole subtree on every
                      keystroke is what this endpoint exists to avoid.
                    properties:
                      page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 25
                      more:
                        type: boolean
                        example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
  "/leader-rounds/rounds/pause-reminders":
    post:
      tags:
      - Leader Rounds
      summary: Pause the weekly due-rounds digest
      description: |
        The leader's own off switch for the Monday digest, for four weeks.
        Delegates to `LeaderRounds::DueReminderPreference`, shared with the web.

        **A SNOOZE, NEVER A DISMISS.** `NotificationDelivery#dismiss!` is
        permanent and has no inverse, so wiring a user-facing pause to it would
        be a one-way trap. `resume-reminders` is the real inverse.

        `reminders_enabled` reflects the TENANT-level setting — there is no point
        offering a personal pause for a digest the whole account has turned off,
        so a client should read it before showing the control. It is NOT the
        personal pause and reads `true` on both sides of one: read `paused` for
        that.

        **The body reports what was written, read back from the ledger** — not
        what the request intended. There is no GET for pause state, so this reply
        is a client's only source of truth for it.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Paused
          content:
            application/json:
              schema:
                type: object
                required:
                - reminders_enabled
                - paused
                properties:
                  reminders_enabled:
                    type: boolean
                    description: The tenant-level digest setting, not the personal
                      pause.
                    example: true
                  paused:
                    type: boolean
                    description: The PERSONAL pause, observed after the write. This
                      is the key that distinguishes a paused leader from an unpaused
                      one — `reminders_enabled` cannot, because it answers a different
                      question.
                    example: true
                  paused_until:
                    type: string
                    format: date-time
                    nullable: true
                    description: When the snooze lifts, re-read from the ledger after
                      the write. Null means not currently paused — a snooze already
                      in the past is not a pause.
                    example: '2026-09-15T09:29:55.371Z'
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
  "/leader-rounds/rounds/resume-reminders":
    post:
      tags:
      - Leader Rounds
      summary: Resume the weekly due-rounds digest
      description: |
        The inverse of `pause-reminders` — clears the snooze. Like `pause`, the
        body reports the state READ BACK from the ledger rather than the state the
        request intended, so a successful resume answers `paused: false` and
        `paused_until: null` because that is what the ledger now says.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Resumed
          content:
            application/json:
              schema:
                type: object
                required:
                - reminders_enabled
                - paused
                properties:
                  reminders_enabled:
                    type: boolean
                    example: true
                  paused:
                    type: boolean
                    description: The personal pause, observed after the write.
                    example: false
                  paused_until:
                    type: string
                    format: date-time
                    nullable: true
                    example:
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
  "/leader-rounds/rounds/recap":
    post:
      tags:
      - Leader Rounds
      summary: Turn a spoken recap into suggested answers
      description: |
        Extract suggested answers to a template's questions from a leader's
        free-spoken recap of a rounding conversation.

        **TAKES TEXT, NOT AUDIO.** The client transcribes on-device (iOS
        `SpeechDictationManager` / `SFSpeechRecognizer`), which removes the
        upload, the polling and any transcript storage — and works where the
        network does not, which matters for an app used in stairwells and med
        rooms. The web's `rounds/voicenote` route is unaffected: it exists to run
        Whisper on an uploaded clip, which on-device transcription makes
        unnecessary rather than wrong.

        **IT NEVER PICKS PEOPLE.** `recognition_pick` and `issue_capture`
        questions are excluded from the model's question set entirely, so it
        cannot invent a colleague or hand someone else's name a piece of work.
        Those stay manual; their ids come back in `excluded_question_ids` so a
        client can say so rather than leaving the leader to notice the gap.

        **IT WRITES NOTHING.** The response is suggestions the leader edits and
        then saves through `POST /rounds`. A suggestion the model could not make
        valid (a scale outside 1..5, an off-list choice, a blank) is dropped
        server-side rather than returned — a blank field the leader fills in is
        recoverable; a bad value would 422 at save time and read as the app
        losing their answers.

        Degrades honestly: when the model is unavailable or its output is
        unreadable the response is a 422 whose message tells the leader to type
        the answers instead.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - template_id
              - transcript
              properties:
                template_id:
                  type: integer
                  example: 1
                transcript:
                  type: string
                  description: The on-device transcript of the leader's recap.
                  example: Caught up with Marcus for fifteen minutes. Things are going
                    well since the swap flow shipped. On support he's about a four
                    out of five.
      responses:
        '200':
          description: Suggestions extracted
          content:
            application/json:
              schema:
                type: object
                required:
                - suggestions
                - excluded_question_ids
                properties:
                  suggestions:
                    type: array
                    description: Only questions the recap actually addressed. An omitted
                      question is expected, not a failure.
                    items:
                      type: object
                      properties:
                        question_id:
                          type: integer
                          example: 5
                        question_type:
                          type: string
                          enum:
                          - text
                          - scale
                          - boolean
                          - choice
                        value:
                          description: Typed to the question — string, 1..5 integer,
                            or boolean.
                          oneOf:
                          - type: string
                          - type: number
                          - type: boolean
                  excluded_question_ids:
                    type: array
                    description: Questions deliberately not filled — the recognition
                      pick and the issue owner stay manual.
                    items:
                      type: integer
                    example:
                    - 3
                    - 4
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, the caller is not a leader (`leader_access_required`)
            — this is a cost-bearing model call on the capture path, and its web twin
            is leader-gated — or the tenant has turned AI drafting off (`ai_drafting_disabled`,
            the `ai_drafting_enabled` app setting an app admin controls). The API
            is the door a tenant CANNOT close by hiding a button, so it refuses here
            too; a client should offer the manual form rather than retry.
        '422':
          description: 'Blank transcript (`transcript_required`), inactive template
            (`invalid_template`), the model was unavailable / unreadable (`recap_failed`,
            `recap_unparsable`) — type the answers instead — or the tenant''s AI credit
            cap stopped the call (`ai_usage_blocked`). Render `ai_usage_blocked`''s
            `message` VERBATIM: unlike the others it carries the tenant''s own reason
            (an admin set `ai_monthly_credit_limit`, or unticked "Keep AI running
            past your credit balance", at /admin/billing), and it is the one 422 here
            that retrying cannot clear.'
  "/leader-rounds/scope-tree":
    get:
      tags:
      - Leader Rounds
      summary: One level of the reporting-line tree (scope picker)
      description: |
        Backs the org-chart scope picker on the ledger and the rollup: one level
        at a time, with a breadcrumb back up.

        **THE SHAPE IS DELIBERATELY APP-AGNOSTIC.** Per-node numbers are emitted
        as labelled `metrics[{label, value, tone}]` rather than
        `open_issues`/`red_issues` fields, so the client component that renders
        this can stay ignorant of Leader Rounds and be reused by the next app
        that grows a drill-down. Labels are server-authored, so they localize
        server-side like the rest of the payload.

        **Why not just use `/team`.** On the rollup, `/team?node=` already
        returns this level. But the LEDGER has the same picker and `/issues`
        returns no tree, so driving it from `/team` would construct a full
        coverage report plus a paginated per-leader array and discard nearly all
        of it — once per level, on a phone.

        A row aggregates its **whole subtree**, matching the rollup's unit rows —
        a picker row that counted only a node's own reports would disagree with
        the tile the user lands on after applying it. Colours come from the same
        `Stoplight` scopes the ledger uses, so the picker's "Red" means exactly
        what the ledger's red pill means.

        `Issues` is the subtree's **total** — resolved and won't-fix included —
        because applying the row lands on a ledger whose pills read `All (N)` /
        `Red (M)` over that same subtree, and the two numbers have to agree. It
        is the same pair the web's unit chips carry.

        **Children are ordered worst-first** (most red, then most issues, then
        name), with nodes that hold nothing anywhere in their subtree ordered
        LAST and flagged `quiet: true`. At a tenant root this level is every
        leader in the business — 66 of 69 rows read zero on the dev tenant — so
        alphabetical order buried the few that mattered. Quiet nodes are ordered
        last rather than dropped: on mobile this picker is the only way to reach
        a node, and on the rollup a team with no issues is often the one worth
        opening (nobody has rounded it, so it raises nothing). A client may
        collapse them behind a "show all" that states how many it is hiding.

        **An empty `children` array means the caller has nobody under them**, and
        a client should render no scope launcher at all rather than an empty
        sheet.
      security:
      - BearerAuth: []
      parameters:
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: Anchor at one leader in the caller's tree; blank opens at the
          caller's own default layer. An id outside the tree is refused (`node_not_visible`,
          403), never clamped — the picker only ever offers nodes the server just
          returned, so a bad node means a bug or an enumeration attempt.
      responses:
        '200':
          description: One level of the tree
          content:
            application/json:
              schema:
                type: object
                required:
                - breadcrumb
                - children
                properties:
                  node:
                    type: object
                    nullable: true
                    description: The anchored node, or null at the caller's root.
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  breadcrumb:
                    type: array
                    description: Path from the top of the caller's span down to `node`.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  children:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsScopeNode"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or `node` outside the caller's tree.
  "/leader-rounds/calendar-pairings":
    post:
      tags:
      - Leader Rounds
      summary: Schedule recurring rounding with one person
      description: |
        "Put this on our calendars" — a recurring calendar event per
        (leader, subject) at the template cadence. Delegates to
        `LeaderRounds::CalendarPairingService`, shared with the web.

        **The event is convenience layered on top of the obligation, and can
        never make coverage lie in either direction** — cadence stays computed
        from completed rounds, not from calendar events.

        Degrades rather than failing: a missing Calendar licence, a subject
        outside the caller's reports, and an already-paired subject are all
        refusals with readable messages.

        **`invitee_ok: false` means the event exists on the LEADER's calendar but
        the subject's invite did not persist.** A client must not present that as
        "on both your calendars" — never report a degraded result as healthy.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subject_id
              - weekday
              - time_of_day
              properties:
                subject_id:
                  type: integer
                  example: 1752
                weekday:
                  type: integer
                  minimum: 0
                  maximum: 6
                  description: Day of the week the recurring check-in lands on, 0
                    = Sunday through 6 = Saturday. A day NAME ("Tuesday", "Tue") is
                    also accepted. Anything else is refused with 422 `invalid_weekday`
                    — it is not coerced.
                  example: 2
                time_of_day:
                  type: string
                  pattern: "^([01]?[0-9]|2[0-3]):[0-5][0-9]$"
                  description: Start time in tenant timezone, 24-hour HH:MM. Refused
                    with 422 `invalid_time_of_day` if unreadable — it is not defaulted
                    to midnight.
                  example: '09:00'
      responses:
        '201':
          description: Pairing created
          content:
            application/json:
              schema:
                type: object
                required:
                - pairing
                properties:
                  pairing:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 1
                      subject_id:
                        type: integer
                        example: 1752
                      subject_name:
                        type: string
                        example: Marcus Bell
                  invitee_ok:
                    type: boolean
                    description: False = the subject's invite failed; do not claim
                      both calendars.
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '422':
          description: Calendar app not enabled, subject not an active direct report,
            already paired, a `weekday` that is missing or unreadable (`invalid_weekday`),
            a `time_of_day` that is missing or not 24-hour HH:MM (`invalid_time_of_day`),
            or the calendar call failed. The two time codes are REFUSALS, not coercions
            — the service stopped defaulting an unparseable weekday to Sunday and
            a missing time to midnight, which had booked real recurring midnight-Sunday
            meetings on two calendars behind a 201. An id that is not an active member
            of the tenant and an id that is a member but not the caller's direct report
            answer IDENTICALLY (`invalid_subject`, same body) — the same non-enumeration
            rule this file states for `?leader_id=`.
  "/leader-rounds/calendar-pairings/{id}":
    delete:
      tags:
      - Leader Rounds
      summary: Remove a recurring rounding meeting
      description: |
        Removes the pairing and its calendar event. A pairing is the leader's
        own; app admins may clean up any — the same tier the web applies.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not found, or not a pairing the caller may remove.
  "/comms_hub/dashboard":
    get:
      tags:
      - Comms Hub
      summary: Communications home dashboard
      description: |
        The native-client mirror of the web Communications home
        (`Apps::NewsFeedController#show`) — the "Mobile Comms App" home screen.

        Every number and list is produced by the **same** query object that
        backs the web page (`NewsFeed::DashboardStats`), so the two surfaces
        cannot drift. Each metric is one memoized, business-scoped, N+1-free
        query hanging off a single shared visibility base
        (`accessible_by ∩ published ∩ active`).

        The payload carries the seven Home surfaces:

        * `counts.unread` — audience-visible published posts the caller has not
          opened ("Unread for You").
        * `counts.pending_acknowledgements` — must-read posts still awaiting the
          caller's acknowledgement ("Needs Acknowledgement").
        * `counts.verified_answers` — question posts that carry a verified
          answer ("Verified answers").
        * `have_your_say` — the freshest open poll the caller has not voted on,
          or `null`.
        * `latest` — the freshest published posts as glance rows ("Latest from
          the company"); at most 4.
        * `must_read` — the top unacknowledged must-read with company
          acknowledgement progress, or `null` when the caller is all caught up.
        * `trending_topics` — the most-tagged topics across the last 30 days; at
          most 5.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - dashboard
                properties:
                  dashboard:
                    type: object
                    required:
                    - counts
                    - latest
                    - trending_topics
                    properties:
                      counts:
                        type: object
                        required:
                        - unread
                        - pending_acknowledgements
                        - verified_answers
                        properties:
                          unread:
                            type: integer
                            example: 3
                          pending_acknowledgements:
                            type: integer
                            example: 1
                          verified_answers:
                            type: integer
                            example: 7
                      have_your_say:
                        type: object
                        nullable: true
                        description: The surfaced "Have your say" open poll, or null
                          when none applies.
                        properties:
                          id:
                            type: integer
                          headline:
                            type: string
                            description: Poll headline, falling back to a stripped/truncated
                              body (≤ 90 chars).
                          closes_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the poll closes; null for a poll with
                              no end date ("Poll open now").
                          published_at:
                            type: string
                            format: date-time
                            nullable: true
                      latest:
                        type: array
                        description: '"Latest from the company" glance rows, newest
                          first (≤ 4).'
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            headline:
                              type: string
                              description: Post headline, falling back to a stripped/truncated
                                body (≤ 90 chars).
                            author:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                  nullable: true
                                avatar_url:
                                  type: string
                                  nullable: true
                                  description: Absolute avatar URL (null when it cannot
                                    be resolved).
                            published_at:
                              type: string
                              format: date-time
                              nullable: true
                            topic:
                              type: string
                              nullable: true
                              description: Name of the post's first topic chip, or
                                null.
                            comment_count:
                              type: integer
                              description: Active (non-deleted, non-moderated) comment
                                count.
                            reaction_count:
                              type: integer
                      must_read:
                        type: object
                        nullable: true
                        description: Must-Read status card, or null when the caller
                          is all caught up.
                        properties:
                          id:
                            type: integer
                          headline:
                            type: string
                            description: Post headline, falling back to a stripped/truncated
                              body (≤ 60 chars).
                          expires_at:
                            type: string
                            format: date-time
                            nullable: true
                          expires_in:
                            type: string
                            nullable: true
                            description: Human-readable expiry (e.g. "Expires in 2
                              days"), or null when it never expires.
                          acknowledged:
                            type: boolean
                            description: Whether the calling user has personally acknowledged
                              this post.
                          acknowledgement_count:
                            type: integer
                            description: Company acknowledgement count from the analytics
                              cache (0 until computed).
                          acknowledgement_pct:
                            type: integer
                            nullable: true
                            description: Company acknowledgement percentage (acks
                              ÷ audience), or null until the snapshot exists.
                          policy:
                            type: object
                            nullable: true
                            description: |
                              The HR policy this must-read asks the reader to accept, plus THIS
                              caller's acceptance of it, or null when the post names no policy.
                              Byte-for-byte the same block a feed payload carries (`policy` on
                              `GET /feeds/{id}`) — one serializer feeds both — so the card can
                              draw the whole "Policy you're asked to accept" row (title, an
                              accepted / not-yet-accepted chip, and where to read and accept it)
                              without a follow-up fetch. Acceptance is tracked separately from
                              acknowledging the post; render the two states independently.
                            properties:
                              id:
                                type: integer
                              title:
                                type: string
                              status:
                                type: string
                                enum:
                                - draft
                                - published
                                - archived
                                - retired
                                description: |
                                  The policy's own lifecycle state, verbatim from the record —
                                  HrPolicy validates `draft | published | archived | retired`, and
                                  this block is emitted for whatever policy the must-read names, so
                                  all four are reachable. Only `published` is linkable (`url` and
                                  `acknowledge_url` go null otherwise); keep rendering the title on
                                  an existing must-read whatever the state says.
                              requires_acknowledgment:
                                type: boolean
                                description: Whether the policy asks to be accepted
                                  at all. When false, name the policy without promising
                                  an accept action.
                              accepted:
                                type: boolean
                                description: Whether this caller has accepted the
                                  CURRENT version. False when HR has flagged them
                                  for re-acknowledgment, even though an older acceptance
                                  row exists.
                              accepted_at:
                                type: string
                                format: date-time
                                nullable: true
                                description: When they accepted. Null unless `accepted`
                                  is true.
                              url:
                                type: string
                                nullable: true
                                description: Absolute URL of the mobile Policy Hub
                                  screen (reads AND accepts) — the "Read and accept"
                                  target. Null when the policy is no longer published
                                  or Policy Hub isn't reachable by THIS caller, so
                                  a client never renders a link that only bounces.
                              acknowledge_url:
                                type: string
                                nullable: true
                                description: Absolute URL to POST (empty body) to
                                  accept the policy in place, so the card can offer
                                  the accept action itself. Null whenever that POST
                                  would be refused — see the same field on the feed
                                  payload for the full rule.
                              acknowledge_url_reason:
                                type: string
                                nullable: true
                                enum:
                                - not_published
                                - app_unavailable
                                - not_applicable
                                - esignature_required
                                - not_targeted
                                description: Why there is no in-place accept action
                                  — non-null exactly when `acknowledge_url` is null,
                                  null otherwise. Same codes and rule as the feed
                                  payload's field.
                      trending_topics:
                        type: array
                        description: Most-tagged topics across the last 30 days (≤
                          5).
                        items:
                          type: object
                          properties:
                            name:
                              type: string
                            posts:
                              type: integer
                              description: Number of posts tagged with this topic
                                in the last 30 days.
                  unread_notification_count:
                    type: integer
                    description: Cross-app unread notification badge count (piggybacked
                      on every API response).
                    example: 5
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: The Communications app is not enabled for the business or not
            accessible to the caller
  "/company-store/config":
    get:
      tags:
      - Company Store
      summary: Company Store bootstrap configuration and capabilities
      description: |
        Called **once** when the module opens. Returns the tenant's Company Store
        configuration as *this caller* experiences it, so a client can stop
        hardcoding anything tenant-specific and can gate the manager UI.

        ### The store kill-switch is reported, not enforced

        `store_enabled` is `false` when an admin has paused the store. **This
        endpoint still answers 200** — it is the only one in the namespace that
        does. Every screen endpoint (`/catalog`, `/dashboard`, `/points`,
        `/orders`) continues to answer `403 store_disabled`, which is what makes
        the split useful: a client learns the state here and renders "your
        organization paused the store" rather than showing an empty catalog or a
        generic auth error.

        A paused store reports an **empty** `categories` and `payment_methods` —
        nothing is browsable and nothing is purchasable while it is off — and the
        category count query is skipped rather than charged to every request for
        the duration of the pause.

        ### Gating the manager UI

        Role appears **exactly once**, in `viewer`, and it is the namespace's
        shared card. `viewer.is_manager` is
        `Store::RedemptionApprovalService.can_approve_redemptions?` — the very
        predicate the approvals queue's own door gates on — so a client that hides
        the Approvals tab on this flag never draws a tab that bounces. It is true
        for a designated approver-group member, and for a line manager with direct
        reports when no group is configured; it is **not** a `role: manager`
        membership check.

        `viewer.is_store_admin` is a business admin-or-above or a Company Store
        app-admin, and is what offers the item / order management surfaces.

        There is deliberately **no** second `permissions` block restating those
        two booleans — a parallel spelling is how one surface starts disagreeing
        with another about who may approve.

        ### Categories — the filter sheet, from one source

        `categories` powers the catalog **category-filter bottom sheet**. The
        first row is always **All Categories** (`key: null`) carrying the true
        total; then one row per **enabled** category, *including the ones at 0*,
        so the sheet does not reshuffle as a tenant's stock moves (`has_items`
        exists for a client that wants the web dropdown's behaviour of hiding
        empty rows).

        Two things to get right client-side:

        * **`key` is the value `?category=` takes verbatim**, not the
          merchandising label — the `StoreItem` column vocabulary: `swag`,
          `gift_card`, `experience`, `charitable`. The labels deliberately differ
          from the keys (`charitable` is merchandised as **"Donate"**, and the
          other three are pluralised), which is why both are sent.
        * **`icon` is a stable FontAwesome-style key**, never an image URL —
          `tshirt`, `gift`, `star`, `hand-holding-heart`, with `tag` on the All
          row and `box` as the fallback. These are the same keys the web
          storefront renders, from
          `Apps::CompanyStoreHelper#company_store_category_icon`, so an icon
          change lands on every surface at once.

        A category the admin switched off is **absent** from the array entirely —
        it is not sent with `count: 0`. (`count: 0` means the category is on and
        has no stock right now, which is a different thing and wants different
        copy.)

        ### Payment methods — the tenant's ceiling

        `payment_methods` lists **only the methods the tenant actually accepts**,
        in the order a client should offer them. Render the array verbatim rather
        than filtering it: a method a client has never heard of still appears, and
        one the admin switched off cannot.

        | `key` | `label` | when it appears |
        |---|---|---|
        | `points` | Points | `points_redemption_enabled` |
        | `cash` | Card | `cash_purchases_enabled` |
        | `mixed` | Points + Card | **all three** of the above plus `mixed_payments_enabled` |

        The `mixed` rule is the one that bites. `features.mixed_payments_enabled`
        is the raw admin toggle; a split payment can only be *taken* when the
        store can take both halves, so a tenant with the mixed toggle on but card
        purchases off gets **no** `mixed` method — exactly as the item detail
        sheet's `payment_options` refuses it. A client that reads the raw feature
        flag instead of this array will draw a split-payment button whose checkout
        bounces.

        This is the **tenant's ceiling**, not a per-item answer: an individual
        item may be points-only or card-only, and
        `GET /company-store/catalog/{id}` carries the per-item `payment_options`
        with its own refusal reason for each. No item can ever offer a method
        outside this array.

        ### The currency noun

        `currency_label` (`"points"`) and `currency_label_singular` (`"point"`)
        are sent so no client hardcodes the word. Both are constants today —
        there is no per-tenant setting for them yet — but serving them from here
        means the day one appears, no client needs a release. The singular is
        carried alongside the plural because a client rendering "1 points" has no
        way to derive it.

        ### Redemption rules — disclose before, don't surprise at checkout

        Every figure in `redemption` is **null when off**, never `0`, so a client
        never renders "cap: 0" as "you may redeem nothing".

        * `approval_threshold_points` — the points figure **at or above** which a
          redemption is HELD for approval instead of spending immediately
          (checkout compares `>=`). It is the **lowest enabled** of the admin and
          manager tiers, because that is the one that actually holds; a tier set
          to `0` is off and is ignored. `null` = no tier enabled.
        * `monthly_points_cap` / `monthly_points_remaining` — the per-user
          calendar-month ceiling and what is left of it for **this caller**. The
          remaining figure counts the same set `Store::CheckoutService` counts
          (points and mixed orders, cancelled and refunded excluded), and is
          floored at 0. The query behind it is skipped entirely when no cap is
          configured — which is every tenant that never set one.
        * `velocity_*` — the short-window fraud brake. A breach does **not** block
          the redemption, it forces the approval hold, so disclose it as "this may
          need approval" rather than as a refusal. `velocity_window_hours` reports
          the service's own 24h fallback when unset.

        ### Points

        `points` is the caller's **own** wallet, for every role — there is no
        persona branch here. Keys are the plain nouns a config payload reads best
        with (`balance`, `pending`, `lifetime_earned`, `lifetime_spent`); the
        namespace's `balance_card` spelling (`points_balance`, `pending_points`,
        …) is still served verbatim by every screen endpoint.

        `expiring_points` is what **newly** expires within `expiring_within_days`,
        never the whole expirable pool. Read it with
        `features.points_expiry_enabled`: `0` means "nothing is close" when expiry
        is on, and "this tenant does not expire points" when it is off — the
        second should render no expiry banner at all.

        ### Regions

        `region` is the region this payload was scoped to (`null` when regions are
        off, or when they are on and this user resolves to none — in which case
        the pool narrowed to global items; `features.regions_enabled`
        distinguishes the two). `available_regions` is the vocabulary for a
        client's own picker, with `active` marking the resolved one. Passing
        `?region_id=` scopes the category counts to a sibling region, exactly like
        the web region picker.

        ### Query budget

        Constant. Nothing in this payload scales with the number of categories,
        items, regions or people — the whole category sheet is ONE grouped query,
        and the two per-caller figures that need their own query (the expiring
        slice and this month's committed spend) are skipped entirely unless the
        tenant configured that feature.
      security:
      - BearerAuth: []
      parameters:
      - name: region_id
        in: query
        required: false
        description: Scope the category counts to a sibling region, like the web region
          picker. Ignored when the tenant has no active regions. An id that is not
          an active region of this tenant falls back to the caller's own resolved
          region rather than erroring.
        schema:
          type: integer
      responses:
        '200':
          description: 'The tenant''s Company Store configuration as this caller experiences
            it. Also the answer for a PAUSED store, reported as `store_enabled: false`
            rather than as a 403.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    type: object
                    required:
                    - store_enabled
                    - store_label
                    - currency_label
                    - currency_label_singular
                    - viewer
                    - features
                    - region
                    - available_regions
                    - categories
                    - payment_methods
                    - points
                    - redemption
                    properties:
                      store_enabled:
                        type: boolean
                        description: The tenant's store kill-switch. False = an admin
                          paused the store; `categories` and `payment_methods` are
                          then empty and every screen endpoint answers 403 `store_disabled`.
                          Reported rather than enforced — see the endpoint description.
                      store_label:
                        type: string
                        description: The app's own name, so a console rename needs
                          no client release. Defaults to "Company Store".
                        example: Company Store
                      currency_label:
                        type: string
                        description: What the tenant calls a unit of store currency
                          (plural).
                        example: points
                      currency_label_singular:
                        type: string
                        description: The singular form, sent because a client rendering
                          "1 points" has no way to derive it.
                        example: point
                      viewer:
                        "$ref": "#/components/schemas/CompanyStoreConfigViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStoreConfigFeatures"
                      region:
                        "$ref": "#/components/schemas/CompanyStoreConfigRegion"
                      available_regions:
                        type: array
                        description: Every active region, for a client's own picker.
                          Empty when the tenant has configured none.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreConfigAvailableRegion"
                      categories:
                        type: array
                        description: The catalog category-filter sheet — "All Categories"
                          first, then one row per ENABLED category. Byte-for-byte
                          the same rows GET /company-store/catalog/categories returns.
                          Empty while the store is paused.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreConfigCategory"
                      payment_methods:
                        type: array
                        description: The methods this TENANT accepts, in offer order.
                          Only enabled methods appear — render verbatim rather than
                          filtering. Empty while the store is paused, or when the
                          tenant takes neither points nor cash.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreConfigPaymentMethod"
                      points:
                        "$ref": "#/components/schemas/CompanyStoreConfigPoints"
                      redemption:
                        "$ref": "#/components/schemas/CompanyStoreConfigRedemption"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
              example:
                config:
                  store_enabled: true
                  store_label: Company Store
                  currency_label: points
                  currency_label_singular: point
                  viewer:
                    id: 42
                    name: Anup Kumar
                    image: https://officechat.workforce.mangoapps.com/rails/active_storage/…
                    is_manager: true
                    is_store_admin: false
                  features:
                    points_redemption_enabled: true
                    cash_purchases_enabled: false
                    mixed_payments_enabled: false
                    recognition_integration_enabled: true
                    regions_enabled: false
                    points_per_dollar: 100
                    points_expiry_enabled: false
                  region:
                  available_regions: []
                  categories:
                  - key:
                    label: All Categories
                    icon: tag
                    color: secondary
                    count: 34
                    active: true
                    has_items: true
                  - key: swag
                    label: Swag
                    icon: tshirt
                    color: primary
                    count: 14
                    has_items: true
                    active: false
                  - key: gift_card
                    label: Gift Cards
                    icon: gift
                    color: success
                    count: 9
                    has_items: true
                    active: false
                  - key: experience
                    label: Experiences
                    icon: star
                    color: info
                    count: 5
                    has_items: true
                    active: false
                  - key: charitable
                    label: Donate
                    icon: hand-holding-heart
                    color: warning
                    count: 6
                    has_items: true
                    active: false
                  payment_methods:
                  - key: points
                    label: Points
                    description: Redeem with your points balance
                  points:
                    balance: 4200
                    pending: 300
                    lifetime_earned: 12000
                    lifetime_spent: 7800
                    last_earned_at: '2026-08-19T10:14:02Z'
                    last_spent_at: '2026-07-30T16:02:44Z'
                    points_per_dollar: 100
                    expiring_points: 0
                    expiring_within_days: 14
                  redemption:
                    approval_threshold_points: 5000
                    monthly_points_cap:
                    monthly_points_remaining:
                    velocity_window_hours: 24
                    velocity_max_orders:
                    velocity_max_points:
                unread_notification_count: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            `insufficient_permissions` — the token lacks `read:company_store`.
            Checked first, before the app gate, so this answer can precede
            `access_denied`.

            `access_denied` — the Company Store app isn't enabled for this tenant,
            or this user is outside the app's audience.

            Note this endpoint does **not** answer `store_disabled`. A paused
            store is a 200 with `store_enabled: false`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                      message:
                        type: string
  "/company-store/dashboard":
    get:
      tags:
      - Company Store
      summary: Company Store dashboard
      description: |
        Everything the Dashboard tab renders in ONE request — the native mirror
        of the web Company Store dashboard (`/apps/company-store`) and of the
        Dashboard tab in the Company Store Mobile design.

        Every number and list comes from the same query object the web page uses
        (`Store::DashboardStats`), so the API cannot drift from the page: one
        pool for every grid and count (region + category-enablement + audience
        filters), memoized readings, and lazy invocation so an employee request
        never runs the manager-only approval queries.

        ### What is always present

        `viewer`, `features`, `region`, `balance`, `stats`, `featured_items`,
        `featured_absorbed`, `saved_items`, `points_activity` and
        `recent_orders`.

        ### What is conditional

        Keys that are gated off are **absent**, not null — exactly as the web
        page renders no card at all. `viewer` and `features` tell a client which
        shape it received, so it never has to infer a disabled surface from a
        missing key.

        * **`within_reach`** — the affordability carousel. Present only when the
          surface exists (points redemption on, and a balance to spend). An
          EMPTY array then means "nothing cheap enough yet", which is a
          different message from a tenant that does not redeem points at all.
        * **`earn_onramp`** — the first-run "how to earn" card. Present only for
          a caller with nothing earned, nothing pending and nothing to spend,
          AND only when Recognitions is actually reachable for them — its CTA
          deep-links there, so a link that would bounce is not offered.
        * **`team_approvals`** — the design's manager-only widget. Present only
          for a caller who actually HAS a redemption approval queue, which is the
          same rule (`Store::RedemptionApprovalService.can_approve_redemptions?`)
          the queue page's own gate and the app nav read, so this widget never
          counts holds that page would refuse. It is also exactly what
          `viewer.is_manager` reports.

        `featured_absorbed` is true when the Featured grid came back empty ONLY
        because "Within reach" is already showing those items — a client's empty
        state can then say where they went instead of claiming there are none.
      operationId: companyStoreDashboard
      security:
      - BearerAuth: []
      parameters:
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: Scope the pool to a sibling region, exactly like the web region
          picker. Ignored when the tenant has no active regions. The pool is always
          the selected region PLUS the global items.
      responses:
        '200':
          description: The dashboard
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    properties:
                      viewer:
                        "$ref": "#/components/schemas/CompanyStoreViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStoreFeatures"
                      region:
                        "$ref": "#/components/schemas/CompanyStoreRegion"
                      balance:
                        allOf:
                        - "$ref": "#/components/schemas/CompanyStoreBalance"
                        - type: object
                          properties:
                            expiring_points:
                              type: integer
                              description: What NEWLY expires inside `expiring_within_days`.
                                0 when the tenant does not expire points.
                            expiring_within_days:
                              type: integer
                              description: The same horizon the web banner and the
                                warning notification use, so the number never disagrees
                                with the copy beside it.
                      stats:
                        type: object
                        description: The four tappable stat tiles.
                        properties:
                          available_items:
                            type: integer
                          total_orders:
                            type: integer
                          lifetime_earned:
                            type: integer
                          lifetime_spent:
                            type: integer
                      featured_items:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStoreItemCard"
                      featured_absorbed:
                        type: boolean
                      within_reach:
                        type: array
                        description: ABSENT when points redemption is off or there
                          is no balance to spend. Empty means "nothing cheap enough
                          yet".
                        items:
                          "$ref": "#/components/schemas/CompanyStoreItemCard"
                      saved_items:
                        type: object
                        description: The wishlist preview plus its TRUE total, so
                          "+N more saved" can be accurate.
                        properties:
                          total:
                            type: integer
                          items:
                            type: array
                            items:
                              "$ref": "#/components/schemas/CompanyStoreItemCard"
                      points_activity:
                        type: object
                        description: "`period_days` is reported rather than assumed,
                          so a client's header cannot claim a window the server did
                          not compute."
                        properties:
                          period_days:
                            type: integer
                          earned:
                            type: integer
                          spent:
                            type: integer
                          adjustments:
                            type: integer
                          transaction_count:
                            type: integer
                      recent_orders:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStoreOrderRow"
                      earn_onramp:
                        type: object
                        description: ABSENT unless this is a first-run caller AND
                          Recognitions is reachable for them.
                        properties:
                          headline:
                            type: string
                          body:
                            type: string
                          recognition_url:
                            type: string
                      team_approvals:
                        type: object
                        description: ABSENT unless `viewer.is_manager`. `pending_count`
                          is the FULL queue depth, not the feed length — the feed
                          is capped and a "View All" needs the real number.
                        properties:
                          pending_count:
                            type: integer
                          queue_url:
                            type: string
                          requests:
                            type: array
                            items:
                              "$ref": "#/components/schemas/CompanyStoreApprovalRequest"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:company_store`
            (checked first, before the app gate). `access_denied` — the Company Store
            app is not accessible to this caller. `store_disabled` — the tenant's
            admin has switched the store off."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
  "/company-store/catalog":
    get:
      tags:
      - Company Store
      summary: Browse the catalog
      description: |
        The catalog grid — the native mirror of the web catalog
        (`/apps/company-store/catalog`) and its `/m/` twin, and of the Catalog
        and Featured Items screens in the Company Store Mobile design.

        One request returns everything the screen needs: the page of items, the
        whole filter sheet (category rows with counts, collection chips, the
        sort menu), the caller's wallet, the tenant's payment toggles, the
        resolved region and the region picker's options. A client should not
        need a second call to render the screen.

        ### Filtering, sorting and paging

        Every filter is optional and they all compose. `category`, `sort`,
        `collection`, `search`, `region_id` and `page`/`per_page` behave exactly
        as they do on the web page, because the same query object applies them.

        An unrecognised `category` or `sort` is **dropped**, not rejected and
        not left to silently empty the grid — and `filters.applied` reports what
        was actually applied, so a client can see its parameter was ignored.

        `collection` is the exception, because collections are free-form
        per-tenant tags with no fixed vocabulary to validate against: an
        unknown one is **applied** and returns an empty grid rather than being
        dropped, and `filters.applied.collection` echoes it back. Read
        `filters.collections` for the collections that actually hold an item.

        A non-numeric `page` is page 1; a non-numeric or zero `per_page` falls
        back to the default of 20 rather than to 1.

        `featured=true` is **not** the same as `sort=featured`: the filter
        returns only featured items (the design's Featured Items screen), while
        the sort orders featured rows first and still returns everything.

        ### What is in the pool

        Only `available` items — active, and either unlimited inventory or some
        left. On top of that, three narrowings apply to the grid AND to every
        count in `filters`, because a count drawn from a wider pool advertises
        results the grid cannot produce:

        * **Category enablement** — an admin can switch a whole category off.
          Experiences and Donate are OFF out of the box.
        * **Region** — when the tenant has active regions, the pool is the
          requested (or the caller's own resolved) region PLUS the global items.
        * **Audience targeting** — an item restricted to a recipient group is
          absent for everyone outside it, and 404s on deep link.

        ### Prices are gated, not just reported

        `points_price` is present only while points redemption is on AND the
        item carries one; `cash_price` only while card purchases are on (which
        is OFF by default) AND the item carries one. An item with no open
        payment path reports both as `null` and `price_label` as
        `"Not currently redeemable"`. This is the same rule the web card
        applies, and it exists so an item can never advertise a price the store
        would refuse to take.

        ### `can_quick_redeem`

        True only when the server would actually accept a one-tap redemption:
        points on, a points price, an enabled category, in stock, no variants to
        pick, no address to collect, no engraving to collect, and affordable on
        today's balance. It is the same guard set the redeem endpoint enforces,
        so a client that trusts it never shows a button that bounces.
      operationId: browseCompanyStoreCatalog
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        required: false
        schema:
          type: string
          enum:
          - swag
          - gift_card
          - experience
          - charitable
        description: Narrow to one category. An unrecognised value is dropped. A category
          the tenant has switched off yields an empty grid, which is the same answer
          the web page gives.
      - name: collection
        in: query
        required: false
        schema:
          type: string
        description: Narrow to one merchandised collection ("travel", "5-year-anniversary",
          …). Case- and whitespace-insensitive, matching how collections are stored.
      - name: featured
        in: query
        required: false
        schema:
          type: boolean
        description: Featured items only — the design's Featured Items screen.
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Free text over item name and description. A whitespace-only term
          is treated as no search. Also applied to every count in `filters`, because
          each filter row preserves the active term.
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - featured
          - popular
          - price_low
          - price_high
          - name
          - newest
          default: featured
        description: The same six strategies the desktop sort dropdown offers. `popular`
          ranks by redemptions plus purchases. An unrecognised value falls back to
          `featured`.
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: Browse a sibling region, exactly like the web region picker.
          Ignored when the tenant has no active regions, or when the id is unknown
          (in which case the caller's own resolved region is used). The pool is always
          the selected region PLUS the global items.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        description: Clamped to 50. A non-numeric or zero value falls back to 20.
      responses:
        '200':
          description: A page of the catalog, with the filter sheet and the caller's
            context
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    allOf:
                    - "$ref": "#/components/schemas/CompanyStoreContext"
                    - type: object
                      properties:
                        filters:
                          "$ref": "#/components/schemas/CompanyStoreFilters"
                        items:
                          type: array
                          items:
                            "$ref": "#/components/schemas/CompanyStoreItemCard"
                        meta:
                          "$ref": "#/components/schemas/CompanyStorePageMeta"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
              example:
                catalog:
                  viewer:
                    id: 412
                    name: Priya Raman
                    image: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/avatar.jpg
                    is_manager: false
                    is_store_admin: false
                  features:
                    points_redemption_enabled: true
                    cash_purchases_enabled: false
                    mixed_payments_enabled: false
                    recognition_integration_enabled: true
                    regions_enabled: true
                    points_per_dollar: 100
                  region:
                    id: 3
                    name: United States
                    country_code: US
                  balance:
                    points_balance: 6250
                    pending_points: 300
                    lifetime_points_earned: 24800
                    lifetime_points_spent: 18550
                    last_earned_at: '2026-08-15T08:00:00Z'
                    last_spent_at: '2026-08-18T10:19:00Z'
                  redemption:
                    approval_threshold_points: 5000
                  available_regions:
                  - id: 3
                    name: United States
                    country_code: US
                    active: true
                  - id: 4
                    name: United Kingdom
                    country_code: GB
                    active: false
                  filters:
                    applied:
                      category: swag
                      collection:
                      featured: false
                      search:
                      sort: featured
                      region_id: 3
                    sort_options:
                    - key: featured
                      label: Featured
                      active: true
                    - key: popular
                      label: Most Popular
                      active: false
                    - key: price_low
                      label: 'Points: Low to High'
                      active: false
                    - key: price_high
                      label: 'Points: High to Low'
                      active: false
                    - key: name
                      label: Name A-Z
                      active: false
                    - key: newest
                      label: Newest
                      active: false
                    categories:
                    - key:
                      label: All Categories
                      icon: tag
                      color: secondary
                      count: 20
                      active: false
                      has_items: true
                    - key: swag
                      label: Swag
                      icon: tshirt
                      color: primary
                      count: 6
                      active: true
                      has_items: true
                    - key: gift_card
                      label: Gift Cards
                      icon: gift
                      color: success
                      count: 6
                      active: false
                      has_items: true
                    collections:
                    - key: travel
                      label: Travel
                      count: 4
                      active: false
                  items:
                  - id: 1
                    name: Company Logo Hoodie
                    description: Soft fleece pullover hoodie with an embroidered company
                      logo.
                    category: swag
                    category_label: Swag
                    category_icon: tshirt
                    category_color: primary
                    collection:
                    image_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                    featured: true
                    status: active
                    status_label: Active
                    available: true
                    low_stock: false
                    inventory_count:
                    requires_shipping: true
                    region:
                      id: 3
                      name: United States
                    points_price: 4500
                    cash_price:
                    price_label: 4500 pts
                    redeemable_with_points: true
                    purchasable_with_cash: false
                    url: https://acme.workforce.mangoapps.com/apps/company-store/catalog/1
                    affordable: true
                    points_to_go: 0
                    progress_percent: 100
                    ready_to_redeem: true
                    stock_label: In stock
                    stock_detail_label: In stock
                    has_variants: true
                    variant_types:
                    - size
                    - color
                    digital_delivery: false
                    personalizable: false
                    wishlisted: false
                    can_quick_redeem: false
                  meta:
                    current_page: 1
                    per_page: 20
                    total_count: 6
                    total_pages: 1
                    has_next_page: false
                    has_prev_page: false
                unread_notification_count: 3
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:company_store`
            (checked first, before the app gate). `access_denied` — the Company Store
            app is not accessible to this caller (not enabled for the tenant, or the
            caller is outside the app's visibility rules). `store_disabled` — the
            tenant's admin has switched the store off."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
              examples:
                store_disabled:
                  value:
                    error:
                      code: store_disabled
                      message: The Company Store is currently disabled
  "/company-store/catalog/categories":
    get:
      tags:
      - Company Store
      summary: Catalog filter sheet
      description: |
        The category sheet on its own, so a client can open the filter without
        pulling a page of items — the same read split
        `GET /recognitions/leaderboard/categories` makes.

        Returns the **same** `filters` block the listing returns, built from the
        same code, so the two can never disagree about a count. `search` and
        `region_id` are honoured for the reason the web filter rows honour them:
        every row preserves the active term, so a count computed without it
        would advertise results the filtered grid cannot produce.

        The shared context blocks (`viewer`, `features`, `region`, `balance`,
        `redemption`, `available_regions`) ride along, which makes this a
        reasonable "open the store" first call for a client that wants the
        filter sheet before the first grid. There is no `items` or `meta`.
      operationId: companyStoreCatalogCategories
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Scope the counts to a search term, as the grid's own rows do.
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: Scope the counts to a region.
      responses:
        '200':
          description: The filter sheet
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    allOf:
                    - "$ref": "#/components/schemas/CompanyStoreContext"
                    - type: object
                      properties:
                        filters:
                          "$ref": "#/components/schemas/CompanyStoreFilters"
                  unread_notification_count:
                    type: integer
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions`, `access_denied` or `store_disabled`
            — see the listing endpoint."
  "/company-store/catalog/{id}":
    get:
      tags:
      - Company Store
      summary: One catalog item
      description: |
        Everything the item detail screen renders: the card's own fields plus the
        photo strip, the variant groups, every payment method with its own
        refusal reason, the web page's Item Details block, the caller's
        wishlist / back-in-stock watch state, and the related-items strip.

        ### Unavailable items are served on purpose

        An out-of-stock, coming-soon, discontinued or draft item returns **200**,
        exactly as the web detail page renders it — with a status alert instead
        of the buy controls. Read `available`, `status`, `status_label`,
        `stock_label` and `payment_options[].block_reason` and render the same
        way. Hiding the item would break a deep link from an order, a
        notification or a wishlist.

        Region and audience restrictions DO refuse, because those are disclosure
        boundaries rather than states:

        * **audience** → `404 not_found`, with copy that deliberately does not
          reveal that the item exists or who is in the group. Identical to the
          answer a genuinely missing id gets.
        * **region** → `403 region_restricted` (or `region_unavailable` when the
          tenant has regions on and this caller resolves to none). Regions are a
          merchandising axis, not a secret, so the reason is named and a client
          can offer the switch.

        ### `payment_options`

        One entry per payment path the ITEM has a price for — a method the item
        cannot be bought with at all is omitted entirely, because there is no tab
        to render. A method the TENANT has switched off is present with
        `available: false` and a `block_reason`, because that is a state an admin
        can change and a client should be able to explain it.

        The availability rules are the ones the web checkout builds its payment
        options from, including the two that matter most: `points` requires the
        caller's balance to actually cover it (`block_reason: "Insufficient
        points"`), and `mixed` requires the card AND points toggles to both be
        on, because a split spends from both.

        Whether the user has finished choosing variants is client state, so it is
        reported as `has_variants` / `variants` rather than folded into
        `block_reason` — otherwise every freshly-opened detail screen would look
        broken.
      operationId: showCompanyStoreCatalogItem
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: StoreItem id.
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: The region to evaluate access in. A region-specific item is reachable
          only from its own region; global items are reachable from every region.
      responses:
        '200':
          description: The item, with the caller's context
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    allOf:
                    - "$ref": "#/components/schemas/CompanyStoreContext"
                    - type: object
                      properties:
                        item:
                          "$ref": "#/components/schemas/CompanyStoreItemDetail"
                  unread_notification_count:
                    type: integer
              example:
                catalog:
                  item:
                    id: 1
                    name: Company Logo Hoodie
                    description: Soft fleece pullover hoodie with an embroidered company
                      logo.
                    category: swag
                    category_label: Swag
                    category_icon: tshirt
                    category_color: primary
                    image_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                    large_image_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                    featured: true
                    status: active
                    status_label: Active
                    available: true
                    low_stock: false
                    inventory_count:
                    stock_label: In stock
                    stock_detail_label: In stock
                    requires_shipping: true
                    points_price: 4500
                    cash_price:
                    price_label: 4500 pts
                    redeemable_with_points: true
                    purchasable_with_cash: false
                    affordable: true
                    points_to_go: 0
                    progress_percent: 100
                    ready_to_redeem: true
                    has_variants: true
                    variant_types:
                    - size
                    - color
                    digital_delivery: false
                    personalizable: false
                    wishlisted: true
                    restock_watch: false
                    can_quick_redeem: false
                    category_enabled: true
                    requires_approval: false
                    photos:
                    - url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                      alt: Company Logo Hoodie
                    - url: https://cdn.mangoapps.com/library/hoodie-back.jpg
                      alt: Company Logo Hoodie photo 2
                    variants:
                    - type: size
                      label: Size
                      required: true
                      options:
                      - S
                      - M
                      - L
                      - XL
                    - type: color
                      label: Color
                      required: false
                      options:
                      - Black
                      - Heather Gray
                    item_details:
                      category: swag
                      category_label: Swag
                      sku: SWAG-HOOD-01
                      provider_type: internal
                      provider_label: Internal
                      requires_shipping: true
                      estimated_delivery_days: 5
                      delivery_label: 5 business days
                      fair_market_value: 45.0
                    payment_options:
                    - key: points
                      label: Redeem with Points
                      points_required: 4500
                      cash_required:
                      available: true
                      block_reason:
                    - key: cash
                      label: Buy with Card
                      points_required:
                      cash_required: 45.0
                      available: false
                      block_reason: Card purchases aren't enabled for this store
                    related_items: []
                unread_notification_count: 3
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` / `access_denied` / `store_disabled`
            (see the listing endpoint), or `region_restricted` / `region_unavailable`
            for an item outside the caller's region."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
              examples:
                region_restricted:
                  value:
                    error:
                      code: region_restricted
                      message: This item is not available in your region.
        '404':
          description: No such item in this tenant — or an item restricted to a recipient
            group this caller is not in. Deliberately the same answer for both, so
            the response cannot disclose that a targeted item exists.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
              examples:
                audience_restricted:
                  value:
                    error:
                      code: not_found
                      message: This item isn't available for redemption right now.
  "/company-store/catalog/{id}/checkout":
    get:
      tags:
      - Company Store
      summary: Price a checkout and disclose what will happen
      description: |
        Everything the **Checkout** screen renders before anything is committed —
        the native mirror of the web checkout GET.

        ### What it answers

        * **`payment_options`** — every path this item *could* take, each with
          `available` and, when it isn't, a `block_reason` written for the buyer.
          Built by the same method `GET /catalog/{id}` uses, so the item screen
          and the checkout screen cannot disagree. Two rules here are
          load-bearing and have both been bugs: `points` needs the wallet to
          actually cover it, and `mixed` needs **both** the cash and points
          toggles on, not just the mixed one.
        * **`totals`** — what each path costs at this `quantity`, priced through
          `::Store::CheckoutService`'s own public conversions rather than
          re-derived here.
        * **`shipping.collected_by`** — `app` or `stripe`. This is the one thing a
          native client cannot work out for itself: with Stripe Tax on, a
          shippable item's address is collected by **Stripe** so `automatic_tax`
          can compute destination tax on it, and your form must skip its address
          step or the buyer types it twice.
        * **`disclosures`** — whether this redemption will be **held for
          approval** (and by which tier), whether the velocity brake will hold
          it, and the monthly points cap with what is left of it. For a one-tap
          redeem this is the only place a hold can be disclosed at all.

        ### The mixed-payment slider

        `totals.mixed.max_points` is the slider's ceiling:
        `min(points needed to cover the whole price, the caller's balance)`.
        Spend past it and there is no card portion left, which the write detects
        and routes to the points path — so a slider allowed past the ceiling
        silently changes which payment type the buyer gets.

        Pass `points_to_use` to have the split quoted back:
        `totals.mixed.cash_after_points` is what the card will be charged.

        ### It does not refuse an unbuyable item

        An out-of-stock, inactive, or switched-off-category item still answers
        **200**, with `can_checkout: false` and a `block_reason` on every path.
        A 403 here would make "this category is switched off" indistinguishable
        from "you are not in this item's audience", and that distinction is
        exactly what must not be disclosed. Region and audience restrictions DO
        refuse (404/403), because those are disclosure boundaries rather than
        states.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The numeric store item id.
        schema:
          type: integer
      - name: quantity
        in: query
        required: false
        description: Prices every path at this quantity. Clamped to 1..99, and `quantity.max`
          reports the real ceiling for this item (its tracked inventory, when it tracks
          any).
        schema:
          type: integer
          default: 1
      - name: payment_type
        in: query
        required: false
        description: Which path the buyer picked on the item screen. Echoed back as
          `selected_payment` so their tap is not lost; ignored (and replaced with
          the first available path) when this item cannot take it.
        schema:
          type: string
          enum:
          - points
          - cash
          - mixed
      - name: points_to_use
        in: query
        required: false
        description: For `mixed` — quotes the split for this many points without committing
          anything.
        schema:
          type: integer
      responses:
        '200':
          description: The priced checkout. Nothing has been written.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutPreviewResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            * `insufficient_permissions` — the token lacks `read:company_store`
              (checked first, before the app gate). This preview is a GET; the
              POST below requires `write:company_store`.
            * `access_denied` — no Company Store access.
            * `store_disabled` — the tenant paused the store.
            * `region_restricted` — the item belongs to another region (or
              `region_unavailable` when the tenant has regions on and this caller
              resolves to none). Regions are a merchandising axis, not a secret,
              so the reason is named and a client can offer the switch.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: |
            `not_found` — no such item in this tenant, OR it is restricted to an
            audience group this caller is not in. Deliberately the same answer,
            with copy that does not reveal that the item exists or who is in the
            group. Same contract as GET /company-store/catalog/{id}.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
    post:
      tags:
      - Company Store
      summary: Place an order — points, cash, or mixed
      description: |
        **The write.** One endpoint, three payment modes, exactly as the web
        `#process_checkout` is one action with three branches.

        ### Points

        Placed in this request. `placed: true`, and the response carries the
        order and the wallet **after** the debit.

        `status` may be `pending_approval`: high-value redemptions and any order
        tripping the velocity brake are HELD, with the points deducted but no
        fulfilment until an approver releases it. **Do not celebrate a hold** —
        `requires_approval` and the `message` both say so, and the design's own
        flow branches on it.

        ### Cash and mixed

        The order is created **pending**: the points portion is debited and the
        stock is reserved *before* the buyer pays, because that is the only way
        to stop two people spending the same points or buying the last unit.
        `placed: false`, and `payment` carries the Stripe Checkout URL.

        Open `payment.checkout_url` in the system browser. Then either intercept
        `payment.return_url_prefix` / `payment.cancel_url_prefix` on the
        in-browser navigation, or simply poll `payment.complete_url` once the
        browser closes — they are the same call.

        The success and cancel URLs are the app's own, and are deliberately
        **not** client-supplied: a caller-controlled `success_url` is an open
        redirect that we would also be handing to Stripe, and the service
        resolves the item's product image against it (a custom app scheme there
        ships a session with no images).

        ### Every toggle is re-enforced here

        `payment_type` is user-controlled, so the GET's filtering is a courtesy,
        not the gate. `points_to_use` is additionally forced to **0** for a
        `cash` checkout — without that, a hidden mixed-payment slider leaking a
        value into a card-only submit silently spends points.

        ### Idempotency

        Send `idempotency_key`. A repeat returns the SAME order — and for a card
        checkout, the same still-open Stripe session — instead of a second
        pending order and a second points debit. If the retried session is no
        longer payable (already paid, or expired), the response reports the order
        alone with no `payment` block rather than handing back a dead URL.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The numeric store item id.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/CompanyStoreCheckoutRequest"
      responses:
        '200':
          description: 'Either the order was PLACED (`placed: true`, points path)
            or a Stripe session is waiting to be paid (`placed: false`, with `payment`).
            Branch on `placed`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutWriteResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            * `insufficient_permissions` — the token lacks `write:company_store`
              (checked first, before the app gate).
            * `access_denied` — no Company Store access.
            * `store_disabled` — the tenant paused the store.
            * `region_restricted` / `region_unavailable` — the item belongs to
              another region, or the tenant has regions on and this caller
              resolves to none.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: |
            `not_found` — no such item in this tenant, OR it is restricted to an
            audience group this caller is not in. Deliberately the same answer,
            so a deep link cannot confirm that a targeted item exists.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '409':
          description: |
            `checkout_session_unavailable` — an idempotent RETRY (same
            `idempotency_key`) whose order is still awaiting payment but whose
            Stripe session can no longer be reopened: it expired, it was closed,
            or Stripe could not be reached. Nothing was written and nothing was
            paid; the points are still held and the stock still reserved.

            `error.details` carries `order`, `wallet`, and both closing
            endpoints — `complete_url` in case the session WAS paid before it
            closed, `abandon_url` to hand the points and the stock back now
            rather than waiting for the 25h sweep.

            This is NOT reported as a success: `placed: true` means "nothing
            further is owed", and an unpaid pending order does not qualify.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '422':
          description: |
            Nothing was written. Every code here is a refusal of this request
            given current state, which is what a client retries with different
            input.

            * `invalid_payment_type` — not one of points / cash / mixed.
            * `points_disabled` — the tenant switched redemption off, or this
              item carries no points price. Answers a `mixed` submit carrying
              `points_to_use > 0` as well as a `points` one: a split payment
              spends points, so it needs the points toggle too — which is why
              `payment_options[mixed].available` requires all three.
            * `cash_disabled` — the tenant switched card payments off.
            * `mixed_disabled` — the tenant switched split payments off.
            * `item_unavailable` — the item's category is switched off. Note the
              GET still renders such an item; only the write refuses.
            * `variants_required` — the item has options and the selection is
              incomplete.
            * `checkout_failed` — the service refused, and `message` is its own
              prose written for the buyer: not enough points (with the numbers),
              the team's store budget is exhausted, it just sold out, the
              monthly cap would be crossed, engraving details are missing, or
              Stripe rejected the session. Deliberately one code — the service
              does not return machine-readable reasons, and matching on its
              strings here would break the moment the copy is edited. Show
              `message` to the buyer.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
  "/company-store/checkout/{order_number}/complete":
    post:
      tags:
      - Company Store
      summary: Confirm the card leg of a checkout (idempotent — poll this)
      description: |
        Closes the round trip: verifies with Stripe that the session was paid,
        captures the payment intent, flips the order to `processing`, copies back
        any address Stripe collected, fulfils digital items inline, enqueues
        provider fulfilment, and sends the buyer's confirmation **once**.

        ### This is a polling endpoint

        It is idempotent by design, because a native return trip may never
        arrive. All of these are the same call:

        * the client intercepted the success URL — confirm now;
        * the browser closed and the client doesn't know what happened — ask;
        * the app is reconciling on next launch, an hour later — ask again.

        An order already `processing`/`fulfilled` answers **200** with the order
        and does **not** re-send the confirmation email or the admin alert (the
        service's own first-capture guard). That 200 holds even when Stripe
        cannot be re-reached to re-verify a capture that already happened —
        `processing` is only ever written by the capture itself, so the status is
        the proof, and a polling client must not be handed `checkout_closed`
        (whose contract is "terminal, stop polling") for a paid order that is
        being prepared.

        ### Why a webhook is not doing this

        There is no `checkout.session.completed` handling for store sessions —
        see the file header. This endpoint and the 6-hourly
        `CompanyStore::StaleCheckoutSweepJob` are the only two paths that will
        ever confirm a store payment, which is why a client should poll rather
        than assume the redirect landed.

        ### Own checkouts only

        Completing a payment is the buyer closing their own round trip; there is
        no surface anywhere, web included, where one person finishes another's.
        Somebody else's order is **404**, not 403 — whether that order number
        exists is not this caller's business.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/CompanyStoreCheckoutOrderNumber"
      responses:
        '200':
          description: 'Paid and confirmed — or already was. `placed: true` and the
            order is `processing` or beyond.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutCompleteResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`)
            / `access_denied` / `store_disabled`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: No such order number among this caller's own orders in this
            tenant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '409':
          description: |
            Nothing changed. `error.details.order` carries the order's current
            state so a polling client can decide what to do without a second
            request.

            * `payment_incomplete` — the order is still `pending` and Stripe says
              the session is not paid. The buyer hasn't finished. **Keep
              polling** (or stop and let the sweep reconcile).
            * `checkout_closed` — the order is terminal (cancelled, refunded, or
              swept). **Stop polling and refresh.** The service refuses to
              resurrect a terminal order, and if that session was in fact paid in
              the race window it refunds the buyer rather than re-fulfilling.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '422':
          description: "`no_checkout_session` — this order was not paid by card (a
            points-only redemption), so there is no payment to confirm."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
  "/company-store/checkout/{order_number}/abandon":
    post:
      tags:
      - Company Store
      summary: Abandon an unpaid checkout and hand the points back now
      description: |
        The native equivalent of the web's Stripe cancel landing. Cancels the
        pending order and immediately restores the points, the team budget
        draw-down and the reserved stock, then expires the Stripe session so it
        can no longer be paid.

        **Why this endpoint has to exist:** a cash/mixed order is created before
        payment, so a buyer who backs out of the browser leaves points debited
        and stock reserved. The web flow learns about it from Stripe's own cancel
        link. A native client has no such link — it has to say so, and without
        this call those points stay held until
        `CompanyStore::StaleCheckoutSweepJob` runs, up to ~31 hours later.

        Call it when the buyer dismisses the payment browser without paying.

        Only a **pending** order can be abandoned. A paid one needs a
        cancellation (which carries a card refund) — that is
        `POST /company-store/orders/{order_number}/cancel`, and the 409 here
        points at it.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/CompanyStoreCheckoutOrderNumber"
      responses:
        '200':
          description: Cancelled. Points, budget and stock restored.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutAbandonResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`)
            / `access_denied` / `store_disabled`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: No such order number among this caller's own orders in this
            tenant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '409':
          description: |
            * `not_abandonable` — the order is no longer pending. If it was paid,
              cancel it instead; `error.details.cancel_url` is the endpoint to
              use.
            * `checkout_closed` — it moved out from under the request (paid or
              swept in that instant). Refresh the order.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '422':
          description: "`no_checkout_session` — a points-only order has no checkout
            to abandon. Cancel it instead."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
  "/company-store/shipping-countries":
    get:
      tags:
      - Company Store
      summary: Shipping countries and their states/provinces
      description: |
        Everything needed to render the checkout's country and state dropdowns:
        the shippable countries, each country's states nested underneath it, and
        the values the checkout write accepts for both fields.

        Takes no parameters.

        ### Read this before you submit an address

        Render `label`; submit `value`. The two fields' value shapes deliberately
        differ — country submits `"United States"` (the display name) while state
        submits `"CA"` (the 2-letter code). Sending an ISO country code or a
        spelled-out state name is accepted by the checkout and then fails at
        fulfillment, after the points are spent. See this file's header.

        ### Why states are nested rather than a separate lookup

        Nested under their country, so there is no correlation key to get wrong
        and no second request to make. The whole payload is ~52 static rows. The
        cascading per-country fetch pattern used elsewhere in the product
        (`/api/jurisdictions`) exists for the tenant-seeded jurisdiction tree,
        which is thousands of cities deep; this is not that.

        ### When a country has no state list

        `states` is `[]` and `prefill.states_required` is `false`. That pair means
        **render a free-text field**, not "still loading" — a client that showed
        an empty dropdown would leave the buyer unable to complete the address.
        Today every offered country has a list, so this is forward-compatibility,
        not a live case.

        ### `prefill`

        What the web form pre-fills from the caller's own profile, resolved the
        same way. `state` is normalised to a code because a stored profile state
        is not one shape in practice — an imported address may hold
        `"California"`, `"CA"` or `"ca"`. A profile state that resolves to no
        known code comes back **null** rather than a guess: that is exactly the
        case where the buyer must choose, and pre-selecting a wrong state is
        worse than pre-selecting none.

        Never `403`/`404` for a caller past the gates, and never empty — a
        successful response always carries at least one country.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The shippable countries, their states, and the caller's prefill.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreShippingCountriesResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token holds neither `read:company_store`
            nor `read:own_company_store` — checked first, before the app gate), `access_denied`
            (the app isn't accessible to this caller) or `store_disabled` (an admin
            paused the store)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreShippingCountriesError"
  "/company-store/orders":
    get:
      tags:
      - Company Store
      summary: Order history with per-status counts
      description: |
        Everything the **Orders** tab renders in one request: the paginated order
        list, the filter pill row with a **count per status**, the caller's
        role/tenant context, and the page envelope.

        ### Whose orders

        `scope` decides the pool, and it is never widened implicitly:

        * **`mine` (default)** — the caller's own orders. This is what an
          employee, a manager and an admin all get when they open the tab, so an
          admin's Orders screen is their own order history exactly like everyone
          else's.
        * **`all`** — every order in the tenant, the read-side of the web admin
          orders queue. Each row additionally carries `employee`. Available only
          to a **store admin** (a business admin/owner or a Company Store
          app-admin); anyone else gets **403 `forbidden`** rather than a silently
          narrowed list, which would answer a different question than the one
          asked. `can_view_all_orders` tells a client whether to offer the toggle
          at all.

        The mobile design has two personas — Employee and Manager
        ("everything above, plus approves orders") — and the Orders screen is
        **identical for both**. A manager's extra is the separate redemption
        approval queue, not a wider order list, so nothing on this endpoint keys
        off manager status.

        ### Counts and the pill row

        `counts` carries **every** `StoreOrder` status plus `all`, always present
        (0 when empty), from ONE grouped query. `status_filters` is that same
        data arranged as the design's pill row — value, label, count, whether it
        is selected, and whether the web chip would show it.

        Two rules matter and are easy to get wrong client-side:

        * Counts honour **`search`** but NOT **`status`**. Each pill has to report
          how many rows tapping it would land on, so narrowing the counts by the
          currently-selected status would make every other pill read 0. A pill
          that ignored the search term instead would over-count — advertising
          "Fulfilled 12" onto a list of 2.
        * `visible` is false for `pending_approval` and `refunded` while their
          count is 0 and they are not selected — these are rare states and the web
          chips hide them rather than showing a permanent "Refunded 0". The other
          five are always visible. A client can ignore `visible` and render all
          seven; it exists so the pill row can match the web without hardcoding
          the vocabulary.

        `pending_approval` is labelled **"Awaiting Approval"**, not a titleized
        `Pending Approval`, because it would otherwise collide with the separate
        `pending` status on the same screen. Every store surface says the same.

        Note that **`pending` and `pending_approval` are counted separately and
        both appear in the list**, matching the shipped web page rather than the
        mobile design's prototype (which folded held orders into the Pending pill
        while hiding them from the list — so a held order was counted but
        unreachable). A held redemption has the employee's points locked up; it is
        the order they are most likely to be looking for, so it is listed, given
        its own pill, and carries an `approval` block on the detail payload. A
        client that wants the prototype's grouping can add the two counts.

        ### Gotchas

        * An unrecognised `status` is **ignored** (the response falls back to
          All) rather than returning nothing — `filters.status` reports what was
          actually applied, so a client can tell the difference.
        * `per_page` is clamped server-side to 50; `filters.per_page` and
          `meta.per_page` report the value in force.
        * `has_fulfillment_error` is true only while the order is still `pending`.
          A manually resolved order stops advertising the failure that preceded
          it, so a client must not treat a resolved order as failed.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: Narrow to one status. Blank/absent = All. An unrecognised value
          is IGNORED (falls back to All) rather than returning an empty list.
        schema:
          type: string
          enum:
          - pending_approval
          - pending
          - processing
          - fulfilled
          - cancelled
          - refunded
      - name: search
        in: query
        required: false
        description: Case-insensitive match on the order number or the ordered item's
          name — the two things a user remembers about an old order. In the `all`
          scope it additionally matches the employee's name and email. Narrows the
          list AND the counts together.
        schema:
          type: string
      - name: scope
        in: query
        required: false
        description: "`mine` (default) or `all`. `all` is the whole tenant's orders
          and requires a store admin — 403 `forbidden` otherwise. Any other value
          is treated as `mine`."
        schema:
          type: string
          enum:
          - mine
          - all
          default: mine
      - name: page
        in: query
        required: false
        description: 1-based page number. Values below 1 are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Clamped to 50; a non-positive or junk value falls
          back to 20.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The caller's order history, its filter bar and its counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: object
                    required:
                    - viewer
                    - features
                    - scope
                    - can_view_all_orders
                    - filters
                    - counts
                    - status_filters
                    - items
                    - meta
                    properties:
                      viewer:
                        "$ref": "#/components/schemas/CompanyStoreOrdersViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStoreOrdersFeatures"
                      scope:
                        type: string
                        enum:
                        - mine
                        - all
                        description: The pool this page was actually drawn from.
                      can_view_all_orders:
                        type: boolean
                        description: Whether this caller may request `scope=all`.
                          Render the scope toggle from this rather than from a role
                          guess, so a client never offers a switch whose request would
                          403.
                      filters:
                        type: object
                        description: The filter values the server actually APPLIED
                          (not what was sent).
                        properties:
                          status:
                            type: string
                            nullable: true
                            description: null when no status filter is in force (including
                              when an unknown one was dropped).
                          search:
                            type: string
                            nullable: true
                          per_page:
                            type: integer
                            description: The clamped page size in force.
                      counts:
                        "$ref": "#/components/schemas/CompanyStoreOrderStatusCounts"
                      status_filters:
                        type: array
                        description: The design's pill row, in its display order —
                          All, Awaiting Approval, Pending, Processing, Fulfilled,
                          Cancelled, Refunded. The numbers are the same ones `counts`
                          reports.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreOrderStatusFilter"
                      items:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStoreOrderListRow"
                      meta:
                        "$ref": "#/components/schemas/CompanyStoreOrdersPageMeta"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            One of four refusals, distinguished by `error.code`:

            * `insufficient_permissions` — the token lacks `read:company_store`.
              Checked first, before any of the three below.
            * `access_denied` — the Company Store app isn't enabled for this
              tenant, or this user is outside the app's audience.
            * `store_disabled` — the tenant's admin has paused the store.
            * `forbidden` — `scope=all` was requested by someone who isn't a
              store admin.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                        - store_disabled
                        - forbidden
                      message:
                        type: string
  "/company-store/orders/{order_number}":
    get:
      tags:
      - Company Store
      summary: One order in full — timeline, reward payload and available actions
      description: |
        The **Order Detail** screen. Built on the same shared row card the list
        uses, so the two can never disagree about status, money or the item, plus
        everything the detail screen adds.

        ### The timeline

        `timeline` comes from `Store::OrderTimeline`, shared **verbatim** with the
        web page's own Order Timeline card. It is **derived** from the order's
        status, its lifecycle timestamps and its item's delivery shape — which is
        why it can name what is still to come ("On its way · Est. 5 business days
        after it ships"), the thing an employee actually opens this screen for.
        That is deliberately different from the append-only audit trail the web
        *admin* order page renders.

        Steps are ordered and each carries a `state`:

        | state | meaning |
        |---|---|
        | `done` | already happened |
        | `current` | the step the order is standing on |
        | `upcoming` | not reached yet — still named, so the user can see what's next |
        | `cancelled` | terminal: cancelled |
        | `refunded` | terminal: refunded |

        A live order runs `placed → [awaiting_approval] → processing → delivered`;
        a cancelled or refunded one stops at `placed → cancelled|refunded` and
        offers nothing forward-looking, because there is nowhere left to go.

        ### What each role gets

        * **The person who placed it** — everything below, including the
          `fulfillment` reward payload (tracking number and link, gift card code,
          redemption link, delivery email, donation receipt) and the shipping
          address. Exactly what the web order page shows the same person.
        * **A store admin** — may open **anyone's** order (`is_mine: false`), and
          additionally receives the `admin` block: provider order id, provider
          status, funding source and the raw connector `fulfillment_error`. The
          web shows these on the admin order page only; an employee gets
          reassurance copy, never a connector error string.
        * **Anyone else** — 403 `forbidden` on someone else's order.

        ### Actions are affordances, not permissions to guess at

        `actions` is resolved server-side against the very predicates the write
        paths enforce — including this API's own
        `POST :order_number/cancel` — so a control rendered from it is one whose
        write would be accepted, and one the server accepts is never hidden.
        `cancel_blocked_reason` explains a disabled cancel button while the order
        still *looks* cancellable (already dispatched to the reward provider, for
        instance) instead of letting the tap bounce.

        `can_cancel` is offered to **both** people the web offers it to: the buyer
        (self-service) and a store admin on anyone's order, with `cancel_as`
        naming which. The other three — return, report a problem, reorder — are
        the **buyer's own** acts and are `false` for an admin however wide their
        read access, exactly as on the web.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        description: The ORDER NUMBER (e.g. `ORD-20260819-ABC123`), the same identifier
          the web URLs use — not the numeric id.
        schema:
          type: string
      responses:
        '200':
          description: The order, its timeline, its reward payload and the viewer's
            available actions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    "$ref": "#/components/schemas/CompanyStoreOrderDetail"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            `insufficient_permissions` (the token lacks `read:company_store`,
            checked first), `access_denied` (no Company Store access),
            `store_disabled` (the tenant paused the store), or `forbidden` — the
            order belongs to someone else and the caller is not a store admin.
            Deliberately distinct from 404: the order exists in this tenant, the
            caller just may not read it.
        '404':
          description: No order with that number in this tenant.
  "/company-store/orders/{order_number}/cancel":
    post:
      tags:
      - Company Store
      summary: Cancel an order
      description: |
        Cancels the order and reverses everything the purchase did — the native
        twin of **both** web cancel buttons.

        ### Who may cancel

        Resolved server-side by `StoreOrder#cancel_actor_for`, the same rule the
        detail payload's `actions.can_cancel` is built from, so a control rendered
        from that payload is one this endpoint accepts:

        | `actor` | Who | Reach |
        |---|---|---|
        | `owner` | the employee who placed the order | while nothing irreversible has happened — not fulfilled, and not yet dispatched to the reward provider. Mirrors the web order page's own **Cancel order** button. |
        | `admin` | a **store admin** (business admin/owner, or a Company Store app-admin) on anyone's order | the broader power, reaching an order already at the provider. Mirrors the web **admin** order page's Cancel. Cash orders additionally require a business administrator — see below. |

        `owner` wins when a store admin cancels their own cancellable order: the
        narrower claim, and the one the employee-facing note records.

        Everything else the buyer can do (return, report a problem, reorder) stays
        the buyer's alone — an admin is offered none of them on somebody else's
        order.

        ### Cash orders move real money

        Cancelling a cash or mixed order enqueues a **live Stripe refund**
        (`CompanyStore::PaymentRefundJob`), so it requires a **business
        administrator** — a Company Store app-admin is not enough and gets 403
        `cash_reversal_forbidden`, a distinct code because nothing is wrong with
        the request: a different person has to take the action. The buyer's own
        self-service cancel is unaffected — that is the same money going back to
        the same person.

        ### What the cancel does

        The work is `StoreOrder#cancel!` — one row-locked transaction, unchanged
        and unwrapped, exactly as both web buttons invoke it:

        * refunds the points to the employee's wallet, and to the team store
          budget the redemption was drawn from, if any
        * restocks the item's inventory by the order quantity
        * reverses a captured card charge (async — `card_refund_pending` says when
          one is on its way) and expires a still-open Stripe Checkout Session, so
          a cancelled order can't be paid for afterwards
        * clears the stale in-flight fulfilment flags, so a cancelled order stops
          reporting the failure that preceded it
        * notifies the employee (email + in-app) off the status change

        No second notification and no second write are added by this endpoint.

        ### The response

        Carries the **full re-rendered order detail** alongside the cancellation
        receipt, so the client updates the screen it just acted on from this one
        response instead of following it with a `GET`. The receipt's
        `points_refunded` / `card_refund_pending` are the **pre-write** readings —
        after the cancel the points are already back and the refund already
        enqueued, so asking the row afterwards would report `0` / `false` and the
        confirmation would quietly stop naming the money.

        Idempotency: a second cancel of the same order answers 409
        `not_cancellable`. Nothing is refunded twice.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        description: The ORDER NUMBER (e.g. `ORD-20260819-ABC123`), the same identifier
          the web URLs and the detail endpoint use — not the numeric id.
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why it was cancelled. Appended to the order's own audit
                    notes, which is where both web pages record it and where an admin
                    reads it back. Optional; whitespace-only is treated as none given.
                    NOT quoted in the employee's cancellation notification (neither
                    web path quotes one) — it is an audit note, not a message.
                  example: Ordered the wrong size
      responses:
        '200':
          description: Cancelled. Points refunded, inventory restocked, employee notified.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            * `insufficient_permissions` — the token lacks `write:company_store`
              (checked first, before any of the below).
            * `access_denied` — no Company Store access.
            * `store_disabled` — the tenant paused the store.
            * `forbidden` — the order is somebody else's and the caller is not a
              store admin. Retrying will never help; a client should stop
              offering the control.
            * `cash_reversal_forbidden` — a store admin who is not a **business**
              administrator, on a cash/mixed order. The order IS cancellable; a
              different person has to do it.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelError"
        '404':
          description: No order with that number in this tenant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelError"
        '409':
          description: |
            * `not_cancellable` — it IS this caller's to cancel, but the order has
              moved on: already fulfilled, already cancelled or refunded, or (for
              the buyer's own self-service cancel) already dispatched to the
              reward provider. The caller's screen is stale — refresh it. This is
              deliberately **not** a 403: it is a state answer, not a permissions
              one, and a client that hid the control here would hide it for orders
              that are still cancellable.
            * `cancel_failed` — the order moved out from under the request between
              the permission check and the write (cancelled or fulfilled by
              someone else in that window). Nothing was changed; refresh and
              re-read the order.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelError"
  "/company-store/cart":
    get:
      tags:
      - Company Store
      summary: The caller's cart
      description: |
        Every line re-priced from the live item (prices are never stored),
        each with an `issue` when it can't check out as it stands (out of
        stock, region, variant no longer offered …), the totals, the caps a
        stepper needs, the cart's fulfilment route and whether it ships, and
        `blockers` — the reasons a points checkout would be refused, in the
        words the web cart renders beside its disabled Checkout button.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The cart (empty carts answer 200 with no lines).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: "`insufficient_permissions`, `access_denied` or `store_disabled`."
    delete:
      tags:
      - Company Store
      summary: Empty the cart
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The now-empty cart.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
  "/company-store/cart/lines":
    post:
      tags:
      - Company Store
      summary: Add an item to the cart
      description: |
        Adds `quantity` of `item_id` with the given `variants`. The same item
        with the same selection MERGES into its existing line (quantity is
        clamped to 5 and to stock); a different selection is a new line. A
        sixth distinct line is refused `cart_full`.

        Refusal codes (all 422, message written for the buyer): `unavailable`,
        `ineligible` (gift card / donation / engraved / cash-only / switched-off
        category), `route_mismatch`, `currency_mismatch`, `cart_full`,
        `out_of_stock`, `variant_invalid`, `region`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_id
              properties:
                item_id:
                  type: integer
                quantity:
                  type: integer
                  default: 1
                  minimum: 1
                  maximum: 5
                variants:
                  type: object
                  additionalProperties:
                    type: string
                  description: 'The picked options, keyed by variant type (`{ "size":
                    "L", "color": "Navy" }`). Required options must be present.'
      responses:
        '201':
          description: Added (or merged). `added_line_id` names the line; `warnings`
            lists anything clamped.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '422':
          description: Refused — see the codes above.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartError"
  "/company-store/cart/lines/{id}":
    patch:
      tags:
      - Company Store
      summary: Set a line's quantity
      description: "`quantity` 1–5 (clamped to stock); 0 or less removes the line."
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The cart line id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - quantity
              properties:
                quantity:
                  type: integer
      responses:
        '200':
          description: The cart after the change.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '404':
          description: "`line_not_found` — not a line of this caller's cart."
        '422':
          description: Refused (e.g. `out_of_stock`).
    delete:
      tags:
      - Company Store
      summary: Remove a line
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The cart after the removal.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '404':
          description: "`line_not_found`."
  "/company-store/cart/checkout":
    get:
      tags:
      - Company Store
      summary: Preview the cart checkout
      description: |
        Everything the checkout screen renders and nothing re-derived here
        that the write derives differently: the payment paths this order may
        take (`points`, `cash`, `mixed`, each with `available` and a
        `block_reason` when the tenant switched it off or the wallet falls
        short), the one preselected (`points`, or `mixed` when points fall
        short and a split is offered), the split slider's ceiling
        (`amount.points_max`), the shipping prefill and whether Stripe collects
        the address for a given payment type (`shipping.collected_on_stripe_for`),
        and the hold / cap disclosures on the points total.

        A cart with a blocked line answers 422 `cart_blocked` naming the lines;
        an empty cart 422 `cart_empty`.
      security:
      - BearerAuth: []
      parameters:
      - name: payment_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - points
          - cash
          - mixed
        description: Preselect a path (ignored when it isn't available).
      responses:
        '200':
          description: The preview.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartCheckoutPreview"
        '422':
          description: "`cart_empty` or `cart_blocked` (details.issues names the lines)."
    post:
      tags:
      - Company Store
      summary: Place the cart as one order
      description: |
        ONE `StoreOrder` with one line per cart line, priced per selection,
        debiting the points total once. `points` places it in this request
        (`placed: true`; `held_for_approval` when a hold applies). `cash` and
        `mixed` create the order PENDING and answer `placed: false` with a
        Stripe Checkout URL — open it in the system browser, then poll
        POST /checkout/{order_number}/complete; POST /checkout/{order_number}/abandon
        cancels it AND restores the lines to the cart. A `mixed` checkout whose
        points cover the whole price is routed to the points path — branch on
        `placed`, never on what you asked for.

        The cart is emptied on success. Every rule the single-item checkout
        applies (caps, team budgets, approval tiers, velocity brake, inventory
        locks, idempotency) applies to the cart total.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - payment_type
              properties:
                payment_type:
                  type: string
                  enum:
                  - points
                  - cash
                  - mixed
                points_to_use:
                  type: integer
                  description: "`mixed` only — the points portion, up to `amount.points_max`."
                shipping_address:
                  type: object
                  description: Required when `shipping.required` and Stripe does not
                    collect it for this payment type.
                  properties:
                    name:
                      type: string
                    street1:
                      type: string
                    street2:
                      type: string
                    city:
                      type: string
                    state:
                      type: string
                      description: 2-letter code
                    zip:
                      type: string
                    country:
                      type: string
                    phone:
                      type: string
                idempotency_key:
                  type: string
                  format: uuid
                  description: Per-submission token; a retry with the same key returns
                    the same order instead of placing a second.
      responses:
        '200':
          description: 'Placed (`placed: true`) or awaiting card payment (`placed:
            false` + `payment`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartCheckoutResponse"
        '422':
          description: "`cart_empty`, `payment_unavailable`, `shipping_incomplete`,
            or `checkout_failed` with the service's own sentence."
  "/company-store/orders/{order_number}/reorder":
    post:
      tags:
      - Company Store
      summary: Order Again — every line of a finished order back into the cart
      description: |
        The caller's OWN fulfilled or cancelled order, refilled into the cart
        through the one cart brain, so every add rule applies: a line that can
        no longer be added (discontinued, out of stock, switched-off category,
        wrong route for what the cart already holds) is named in `skipped`,
        never dropped silently. The order detail's `actions.reorder_url`
        points here when `reorder_via` is `cart`; a lone gift card or donation
        (`reorder_via: checkout`) still reorders through its single-item
        checkout deep link.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        schema:
          type: string
        description: The ORDER NUMBER (`ORD-…`).
      responses:
        '200':
          description: Lines added; the cart summary to render from.
          content:
            application/json:
              schema:
                type: object
                properties:
                  added:
                    type: integer
                  skipped:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        reason:
                          type: string
                  cart:
                    type: object
                    properties:
                      line_count:
                        type: integer
                      units:
                        type: integer
                      points:
                        type: integer
                  cart_url:
                    type: string
                  message:
                    type: string
        '403':
          description: "`forbidden` — not the caller's own order."
        '409':
          description: "`not_reorderable` — the order is still in progress."
        '422':
          description: "`nothing_added` — none of the lines can be ordered right now
            (the reasons are in the message)."
  "/company-store/orders/{order_number}/requests":
    post:
      tags:
      - Company Store
      summary: Message the store admins about an order
      description: |
        Opens a `StoreOrderRequest` on the caller's OWN order — the native
        twin of the order page's request form. `kind` must be one of the
        kinds the order currently accepts from its owner, which the detail
        payload names in `actions.request_kinds` (`question` on any live
        order; `cancellation` once the order is with the provider and can no
        longer be self-cancelled — `actions.can_request_cancellation`;
        `delivery_status` while the order is in progress; `address_change`
        before dispatch on a shipped order; `change_selection` before dispatch
        on an item with options; `code_issue` once a gift card is issued;
        `payment` and `other` on any live order; `damaged` once a
        physical order is fulfilled). A kind the order doesn't accept is
        refused 422 with the model's own sentence; one open cancellation
        request per order.

        The store admins are told (Inbox + email). A cancellation request
        lands in their queue; approving it recalls the order at the provider,
        cancels it and refunds the points, and the order's own cancellation
        notification tells the employee. Declines and answers reach the
        employee in their Inbox and by email, and appear on the order detail's
        `requests[]` with `resolution_notes`.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        schema:
          type: string
        description: The ORDER NUMBER (`ORD-…`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - kind
              - message
              properties:
                kind:
                  type: string
                  enum:
                  - question
                  - cancellation
                  - delivery_status
                  - address_change
                  - change_selection
                  - damaged
                  - code_issue
                  - payment
                  - other
                message:
                  type: string
                  maxLength: 2000
      responses:
        '201':
          description: Opened. Carries the request, the re-rendered order card and
            a message for the buyer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
                  order:
                    type: object
                    description: The order card
                    as GET /orders renders a row.:
                  message:
                    type: string
        '404':
          description: Not one of the caller's orders (whether it exists is not this
            caller's business).
        '422':
          description: "`request_invalid` — the kind isn't available for this order,
            the message is blank, or a cancellation is already requested."
  "/company-store/admin/order-requests/{id}/approve":
    post:
      tags:
      - Company Store
      summary: 'Admin: approve a cancellation request'
      description: |
        Cancellation requests only. Recalls the order at its provider FIRST
        (`::Store::ProviderCancellation` — the admin cancel button's own step),
        then cancels it locally: points refunded, every line restocked, the
        card charge reversed, the employee notified off the status change. The
        provider's outcome is reported, never swallowed: `provider_cancellation`
        is `{ success: true }`, `{ success: false, error }` when the provider
        refused (the order is still cancelled here — the admin decided knowing
        it may already be in production), or `{ unconfirmed: true, error }` when
        we cannot tell what the provider did. Cash orders require a **business**
        administrator (403 `forbidden`).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Approved and cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
                  order:
                    type: object
                  provider_cancellation:
                    type: object
                    nullable: true
        '403':
          description: "`forbidden` — not a store admin, the token's scope doesn't
            permit it, or a cash order without a business administrator."
        '409':
          description: "`resolved` (already decided), `not_cancellable` or `cancel_failed`
            (the order moved on)."
        '422':
          description: "`invalid` — not a cancellation request."
  "/company-store/admin/order-requests/{id}/decline":
    post:
      tags:
      - Company Store
      summary: 'Admin: decline a request'
      description: The employee sees `reason` in their Inbox and by email. The order
        is unchanged — nothing is refunded.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Declined.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
        '403':
          description: "`forbidden`."
        '409':
          description: "`resolved` — already decided."
  "/company-store/admin/order-requests/{id}/answer":
    post:
      tags:
      - Company Store
      summary: 'Admin: reply to a request'
      description: For questions, address changes and damage reports. `reply` is required
        and reaches the employee in their Inbox and by email.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reply
              properties:
                reply:
                  type: string
      responses:
        '200':
          description: Answered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
        '403':
          description: "`forbidden`."
        '409':
          description: "`resolved` — already decided."
        '422':
          description: "`invalid` — empty reply."
  "/company-store/points":
    get:
      tags:
      - Company Store
      summary: Points balance, activity roll-up and transaction history
      description: |
        Everything the **Points** tab renders in one request: the wallet hero
        with its expiry nudge, the 90-day activity roll-up, the 6-month
        earned-vs-spent trend, the all-time breakdown arranged as a filter row
        with a **count per type**, the paginated transaction history, and the
        self-service tax-statement link.

        ### Whose points

        **The caller's own wallet, for every role.** There is no persona branch
        here and none on the web page either — an employee, a manager and a store
        admin all see their own points and nobody else's. Reading another
        employee's balance is the separate admin balances surface, which has its
        own gate and is not part of this namespace. `viewer` is reported so a
        client can offer those surfaces, but it changes nothing in this payload.

        ### The transaction history and its filter

        `type` narrows the list to one kind of ledger entry, using the same
        validated vocabulary the desktop Transaction Breakdown links and the
        `/m/` pill row use:

        | value | what it is | sign |
        |---|---|---|
        | *(absent)* | everything — the **All** pill | either |
        | `credit` | points earned (recognition, award, released pending points) | positive |
        | `debit` | points redeemed at checkout | negative |
        | `adjustment` | an admin correction, or points clawed back with a deleted recognition | **either** |
        | `expiry` | points aged out by the expiry policy (breakage) | negative |

        `adjustment` being signed **either way** is the one to get right
        client-side: an admin adding points and an admin taking them back are both
        adjustments, which is exactly why every row carries `positive` and the web
        page colours the amount on the sign rather than on the type.

        ### Counts and the pill row

        `counts` carries **every** type plus `all`, always present (0 when empty),
        from ONE grouped query. `type_filters` is that same data arranged as the
        pill row — value, label, count, whether it is selected, and whether the
        web would show it.

        Two rules matter:

        * Counts are **all-time** and are **not** narrowed by `type`. Each pill
          has to report how many rows tapping it would land on, so narrowing by
          the active type would make every other pill read 0. This is the same
          rule the Orders endpoint's status pills follow. Note this means
          `counts.all` is the whole history even when the list is filtered —
          `meta.total_count` is the count of the **filtered** list.
        * `visible` is false for `expiry` while its count is 0 and it is not
          selected. A tenant with expiry switched off never has a single such row,
          and a permanent "Expiry 0" pill is noise; the other four are always
          visible. A client may ignore `visible` and render all five — it exists
          so the pill row can match the web without hardcoding the vocabulary.

        ### Windows are reported, never assumed

        `activity.period_days` is **90** — the window the points *screen* shows,
        which is deliberately **not** the dashboard widget's 30 days. Both
        surfaces report the window they computed so a client's header can't claim
        one the server didn't.

        `trend.months` always holds 6 entries oldest-first, including
        zero-activity months, so a chart renders at a stable width. `earned` and
        `spent` cover credits and debits only — adjustments and expiries land in
        neither series, because this is the earn-vs-redeem picture rather than a
        net-change chart. `trend.has_activity` is what the web page gates the
        whole card on: a flat all-zero chart is worse than no chart.

        ### Expiry

        `balance.expiring_points` is what **newly** expires within
        `balance.expiring_within_days` — the same figure the web banner copy
        claims and the same one the expiry warning notification sends, so the
        number can never disagree with the text beside it. It is **not** the total
        currently-expirable pool.

        Read it together with `features.points_expiry_enabled`: `0` means
        "nothing is close" when expiry is on, and "this tenant does not expire
        points" when it is off. Those want different copy, and the second should
        render no expiry banner or countdown at all.

        ### The tax statement

        `tax_statement.available` mirrors the web header button: offered only when
        the viewer actually has taxable redemptions in a year the statement page
        itself offers, because otherwise the link dead-ends on an empty statement.
        `tax_statement.year` is the year that **has** rows, scanned newest-first
        across the offered range — **not** the current year. In Jan–Apr those are
        usually different, which is exactly when the statement matters most, so a
        client must link to the year reported rather than to "this year".

        ### Gotchas

        * An unrecognised `type` is **ignored** (the response falls back to All)
          rather than returning nothing — `filters.type` reports what was actually
          applied, so a client can tell the difference.
        * `per_page` is clamped server-side to 50; `filters.per_page` and
          `meta.per_page` report the value in force.
        * `source` is a **reference** (`{type, id}`), not a resolved label.
          Resolving it would mean a polymorphic load per row, and the web rows
          print the type and nothing more. Fetch the underlying record by
          reference if a client needs its name.
        * `admin_user` is deliberately absent from adjustment rows: the
          notification an employee receives says "An admin added/removed …" and no
          web surface names the individual. `notes` **is** present — that is the
          admin's stated reason, which the same notification already sends.
      security:
      - BearerAuth: []
      parameters:
      - name: type
        in: query
        required: false
        description: Narrow to one kind of ledger entry. Blank/absent = All. An unrecognised
          value is IGNORED (falls back to All) rather than returning an empty list.
        schema:
          type: string
          enum:
          - credit
          - debit
          - adjustment
          - expiry
      - name: page
        in: query
        required: false
        description: 1-based page number. Values below 1 are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Clamped to 50; a non-positive or junk value falls
          back to 20.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The caller's points balance, roll-ups, filter row and transaction
            page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  points:
                    type: object
                    required:
                    - viewer
                    - features
                    - balance
                    - activity
                    - trend
                    - counts
                    - type_filters
                    - filters
                    - tax_statement
                    - transactions
                    - meta
                    properties:
                      viewer:
                        "$ref": "#/components/schemas/CompanyStorePointsViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStorePointsFeatures"
                      balance:
                        "$ref": "#/components/schemas/CompanyStorePointsBalance"
                      activity:
                        "$ref": "#/components/schemas/CompanyStorePointsActivity"
                      trend:
                        "$ref": "#/components/schemas/CompanyStorePointsTrend"
                      counts:
                        "$ref": "#/components/schemas/CompanyStorePointsTypeCounts"
                      type_filters:
                        type: array
                        description: The pill row, in its display order — All, Credit,
                          Debit, Adjustment, Expiry. The numbers are the same ones
                          `counts` reports.
                        items:
                          "$ref": "#/components/schemas/CompanyStorePointsTypeFilter"
                      filters:
                        type: object
                        description: The filter values the server actually APPLIED
                          (not what was sent).
                        properties:
                          type:
                            type: string
                            nullable: true
                            description: null when no type filter is in force — including
                              when an unknown one was sent and dropped.
                            enum:
                            - credit
                            - debit
                            - adjustment
                            - expiry
                            -
                          per_page:
                            type: integer
                            description: The clamped page size in force.
                      tax_statement:
                        "$ref": "#/components/schemas/CompanyStorePointsTaxStatement"
                      transactions:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStorePointsTransaction"
                      meta:
                        "$ref": "#/components/schemas/CompanyStorePointsPageMeta"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            One of three refusals, distinguished by `error.code`:

            * `insufficient_permissions` — the token lacks `read:company_store`.
              Checked first, before either gate below.
            * `access_denied` — the Company Store app isn't enabled for this
              tenant, or this user is outside the app's audience.
            * `store_disabled` — the tenant's admin has paused the store.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                        - store_disabled
                      message:
                        type: string
  "/company-store/watchlist":
    post:
      tags:
      - Company Store
      summary: Add an item to the watchlist
      description: |
        Saves one store item to the caller's own watchlist (`kind: wishlist`, the
        default) or subscribes them to its back-in-stock alert
        (`kind: restock`).

        **Idempotent.** Adding an item that is already watched is a `200` with
        `changed: false` and one watch row — never a duplicate, and never a
        toggle back off. This is the deliberate divergence from the web button;
        see this file's header.

        ### What comes back

        The state AFTER the write, read back from the database rather than
        assumed — `wishlisted` and `restock_watch` are resolved together in one
        query and are named exactly as the catalog reports them, so a client
        patches its cached card field-for-field without a second call.

        `wishlist_total` is `Store::DashboardStats#wishlist_total` — the very
        figure the dashboard's Saved Items header renders and
        `GET /company-store/dashboard` reports as `saved_items.total`. It is the
        **saved-items** scope, not a raw row count: it omits watches whose item an
        admin has since discontinued, unpublished or restricted to a group this
        caller isn't in, exactly as the dashboard grid omits them. So saving a
        discontinued item is honestly `watching: true` with an unmoved
        `wishlist_total` — the item IS saved, and is genuinely not on the Saved
        Items screen.

        No item card is returned: the caller just tapped the heart on a card it
        already holds, and this namespace has two card shapes (the shared
        dashboard-grid card and the catalog's extended one). Returning either
        would hand clients a third shape to reconcile for no new information.

        ### When `restock` is refused

        A back-in-stock alert is accepted only for an item that is currently
        unavailable AND could plausibly come back — the same condition the web
        detail page renders its button under. `StoreRestockNotifyJob` fires only
        on an `out_of_stock → active` flip, so an alert on an in-stock item would
        never notify anybody, and one on a `discontinued` / `draft` item promises
        a restock that is never coming. Both would be confirmed with "We'll notify
        you when it's back", which the store cannot honour, so both are `422
        restock_not_applicable`.

        Wishlist saves carry no such restriction — saving an unavailable item is
        the whole point of a wishlist, and the catalog card's `status` /
        `available` let a client say so.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_id
              properties:
                item_id:
                  type: integer
                  description: The StoreItem to watch — the `id` every catalog card
                    reports. Resolved within the caller's own business, so another
                    tenant's id is simply not found.
                  example: 412
                kind:
                  type: string
                  enum:
                  - wishlist
                  - restock
                  default: wishlist
                  description: 'Defaults to `wishlist`, so the common call is just
                    an `item_id`. An unrecognised value is `422 invalid_kind` rather
                    than a silent fallback: a client that sent `restok` meant to write
                    a restock alert, and quietly writing a wishlist row instead is
                    a save it never asked for.'
                region_id:
                  type: integer
                  description: 'Optional, and only relevant while the tenant has active
                    store regions: it selects which region the ADD is judged from,
                    exactly like the web region picker and `GET /company-store/catalog/{id}`.
                    Omit it and the caller''s own resolved region applies.'
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
              - item_id
              properties:
                item_id:
                  type: integer
                kind:
                  type: string
                  enum:
                  - wishlist
                  - restock
                  default: wishlist
                region_id:
                  type: integer
      responses:
        '200':
          description: Watched. `changed` is `true` when this call created the watch,
            `false` when it was already there.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistResponse"
        '400':
          description: '`invalid_item_id` — `item_id` was absent, non-numeric, non-positive
            or sent as an array. Answered separately from the `404` on purpose: "no
            such item" and "you didn''t send an item" are different client bugs, and
            an array `item_id` is refused rather than resolved to whichever id sorts
            first inside it (which would be a write against an item the caller never
            named).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`
            — checked first, before the app gate), `access_denied` (the app isn't
            accessible to this caller), `store_disabled` (an admin paused the store),
            `region_restricted` (the item belongs to another region) or `region_unavailable`
            (no store region resolves for this caller)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '404':
          description: "`not_found` — no such item in this business, **or** an item
            restricted to an audience group this caller isn't in. Deliberately the
            same answer with the same copy for both: naming the reason would disclose
            who is in the group."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '422':
          description: "`invalid_kind` (not one of `wishlist` / `restock`) or `restock_not_applicable`
            (the item is in stock, or is discontinued / draft, so the alert could
            never fire). Nothing is written."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
  "/company-store/watchlist/{item_id}":
    delete:
      tags:
      - Company Store
      summary: Remove an item from the watchlist
      description: |
        Removes the caller's own watch of one store item — their saved item
        (`kind: wishlist`, the default) or their back-in-stock alert
        (`kind: restock`).

        **Idempotent.** Removing a watch that isn't there is a `200` with
        `changed: false`, not a `404`: the caller asked for the item to be off
        their watchlist, and it is.

        Only the requested `kind` is removed — an item carrying both a wishlist
        save and a restock alert keeps the other one, and the response's
        `wishlisted` / `restock_watch` report both.

        ### No visibility guards here

        Unlike the ADD, this operation runs **neither** the region nor the
        audience guard. A watch saved before an admin restricted the item, or
        moved it to another region, is still the caller's own row — refusing to
        clear it would leave them holding a saved item they can see on no screen
        and cannot delete. The delete is scoped to the caller's own rows in their
        own business, so it can never reach anyone else's watch.

        A `404` here therefore means only one thing: no such item exists in this
        business (in which case no watch of it can exist either, since watches are
        deleted with their item).
      security:
      - BearerAuth: []
      parameters:
      - name: item_id
        in: path
        required: true
        schema:
          type: integer
        description: The StoreItem id every catalog card reports. Digit-constrained
          by the route, so a non-numeric value does not match this operation at all.
      - name: kind
        in: query
        required: false
        schema:
          type: string
          enum:
          - wishlist
          - restock
          default: wishlist
        description: A query parameter rather than a body field, because a DELETE
          body is not reliably forwarded by every client stack. An unrecognised value
          is `422 invalid_kind` and nothing is deleted.
      responses:
        '200':
          description: Not watched. `changed` is `true` when this call removed a watch,
            `false` when there was nothing to remove.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions`, `access_denied` or `store_disabled`
            — see the ADD operation."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '404':
          description: "`not_found` — no such item in this business."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '422':
          description: "`invalid_kind` — not one of `wishlist` / `restock`. Nothing
            is deleted."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
  "/recognitions/config":
    get:
      tags:
      - Recognitions
      summary: Recognitions configuration
      description: |
        **The first call a client makes.** One request returns everything needed
        to render the role-adaptive Recognitions shell and both composers, so a
        client never has to hardcode the points economy, the message limits, or
        which controls a given viewer may use.

        Every value here is read from the definition the **write path already
        enforces** — the give guard, the reviewer rule, the moderation rule, the
        program eligibility predicate, the model validations, the allowance
        model. So a control you render from this payload is a control whose POST
        the server will accept. Re-deriving any of it client-side is the drift
        this endpoint exists to remove.

        **Persona-aware, not persona-branched.** Every caller receives the SAME
        keys — nothing is omitted by role. Read `permissions` and `features`
        rather than probing for a missing key. What varies is the VALUE of those
        booleans and of the per-viewer economy figures.

        ### The five answers

        * **`module_enabled` / `module_label`** — is Recognize on here, and what
          is it called. Terminology varies per org ("Recognize", "Kudos"), and
          the label comes from the app record, so a console rename reaches the
          app with no client release. `module_enabled` is always `true` in a 200
          (the endpoint 403s otherwise) and is reported so one client model
          covers both answers.
        * **`viewer_role` + `permissions`** — `employee` / `manager` / `admin`,
          and the seven affordance booleans behind it. `manager` means *has
          direct reports*; `admin` means a business admin/owner or a Recognitions
          app-admin, and outranks `manager`.
        * **`features`** — the TENANT switches that decide which surfaces exist
          at all. Kept separate from `permissions` on purpose: hide a tab on
          `features`, disable a button on `permissions`. Collapsing them means an
          employee can't tell "this org doesn't do nominations" from "no program
          accepts me".
        * **`economy` / `limits` / `visibility_options`** — the real, admin
          configurable numbers and the values the server will actually accept.
        * **`values` / `tags` / `cards` / `programs`** — the give composer's and
          the nominate picker's option catalogs, shared verbatim with the web and
          mobile give forms.

        ### Composing a give from this payload

        `values`, `tags` and `cards` are exactly what the web give form offers.
        A card is submitted as its `award_template_id`, which is an **integer**
        for a tenant-authored design and the **string** `"central:<slug>"` for a
        central-gallery one (the server materialises the gallery art into a
        tenant asset on submit) — send the field verbatim rather than
        reconstructing it. `cards.gallery_truncated` is true when the gallery
        page came back full, meaning the catalog holds designs this payload did
        not list; say so in a picker's "no match" state.

        `visibility_options` lists the composer's visibility **and** anonymity
        toggles together, because they are one row of controls on the screen —
        each row names the `param` a client submits it under (`visibility` or
        `is_anonymous`). `department` is the prototype's "my_department"; the
        wire value is `department` because that is what the server stores.
        `private` is offered only when it is the tenant's own default, and
        `anonymous` only while the tenant allows anonymous recognition. The
        legacy `team` synonym is never offered.

        ### Composing a nomination

        `programs.items` is the nominate picker's vocabulary — trimmed to what a
        picker needs, with `can_nominate` resolved per viewer by the same
        predicate the submit path enforces and `nomination_block_reason`
        explaining a `false` in the viewer's own words (**render it** — a
        disabled row with no reason is a dead end). Automatic milestone programs
        are never listed: nobody nominates in them. The list is bounded by
        `programs_limit`; `total_count` and `truncated` report the rest, and the
        browsable, paginated surface is `GET /recognitions/programs`. When
        award requests ("Model A") are off, the list is empty and
        `permissions.can_nominate` is `false`.

        ### Cost

        Flat. Nothing in the payload costs a query per program, value or card,
        so this is safe to call on every launch. The central card gallery is
        cached and failure-tolerant — an unreachable gallery yields an empty
        `cards.gallery` rather than an error.
      security:
      - BearerAuth: []
      parameters:
      - name: programs_limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 12
        description: How many nominatable programs the picker vocabulary carries,
          clamped to 50. `programs.total_count` and `programs.truncated` report the
          rest. This is a composer vocabulary, not a browsable page — use `GET /recognitions/programs`
          to page through them.
      responses:
        '200':
          description: Configuration retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - config
                properties:
                  config:
                    "$ref": "#/components/schemas/RecognitionConfig"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/dashboard":
    get:
      tags:
      - Recognitions
      summary: Recognitions dashboard
      description: |
        The native-client mirror of the web Recognition dashboard. Every number
        and list is produced by the same query object that backs the web page, so
        the two surfaces cannot drift.

        **The response is persona- and setting-aware.** Keys that do not apply
        are **absent** (not null), exactly as the web page renders no card — read
        `viewer` and `features` to know which shape you received:

        * **Every viewer** gets `viewer`, `features`, the `recognition_received`
          and `recognition_given` stats, `recognition`, `my_awards`,
          `trending_recognition` and `top_recipients_this_month`.
        * **Givers** — anyone the tenant lets give peer recognition — additionally
          get `people_to_recognize`, the ranked nudge strip (up to 8 chips) the
          web feed shows above the stream. Absent for a viewer whose give would
          be refused.
        * **Reviewers** — a recognition admin, or anyone with direct reports —
          additionally get `pending_approvals`, the queue the web page banners
          above everything else. Recognition stays hidden from the recipient
          until the reviewer decides, so surfacing this promptly matters.
        * **Award requests on** (Model A — the default) adds
          `stats.nominations`, `my_nominations` and `active_programs`.
        * **Award cycles on** (Model B — off by default) adds `running_program`:
          the time-boxed cycle currently accepting nominations, or `null` when
          none is open.

        Visibility is enforced per row: `recognition` merges the tenant's public
        awards with only the posts this viewer may see (public, own,
        same-department, same-team), and a group give collapses to ONE row naming
        every recipient. Anonymous recognition never names the giver, and
        automated lifecycle awards (anniversaries, birthdays) present as
        `System (Automated)` rather than the system principal.

        List sizes mirror the web widgets: 5 rows each, except
        `trending_recognition` (3), `active_programs` (3) and
        `people_to_recognize` (8).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    required:
                    - viewer
                    - features
                    - stats
                    - recognition
                    - my_awards
                    - trending_recognition
                    - top_recipients_this_month
                    properties:
                      viewer:
                        type: object
                        description: Who the caller is, for the affordances the client
                          shows.
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Anthony Rivera
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey.../a.jpg
                          can_give_recognition:
                            type: boolean
                            description: Whether this viewer may give peer recognition
                              at all — the SAME guard the submit path enforces. Hide
                              the Give affordance when false; the POST would be rejected.
                            example: true
                          is_reviewer:
                            type: boolean
                            description: True for a recognition admin OR anyone with
                              direct reports. Determines whether `pending_approvals`
                              is present.
                            example: false
                          is_recognition_admin:
                            type: boolean
                            description: Business admin (or above), or a per-app admin
                              for Recognitions. Widens the approval queue from "assigned
                              to me" to the whole tenant.
                            example: false
                      features:
                        type: object
                        description: The tenant's nomination surfaces. Tells the client
                          which optional sections to expect, so a disabled surface
                          never has to be inferred from a missing key.
                        properties:
                          award_requests_enabled:
                            type: boolean
                            description: Model A — ad-hoc nominate → approve → award.
                              Default true.
                            example: true
                          award_cycles_enabled:
                            type: boolean
                            description: Model B — time-boxed award cycles. Default
                              false.
                            example: false
                      stats:
                        type: object
                        description: The three stat tiles. Received and Given each
                          roll an award count and a shout-out count into ONE total
                          — render `total` as the figure and the split as its sub-line,
                          which is how the web tile reads.
                        properties:
                          recognition_received:
                            type: object
                            description: Recognition the caller received (awards +
                              shout-outs).
                            properties:
                              total:
                                type: integer
                                example: 21
                              awards:
                                type: integer
                                example: 12
                              shout_outs:
                                type: integer
                                example: 9
                          recognition_given:
                            type: object
                            description: Recognition the caller gave (awards + shout-outs).
                            properties:
                              total:
                                type: integer
                                example: 62
                              awards:
                                type: integer
                                example: 28
                              shout_outs:
                                type: integer
                                example: 34
                          nominations:
                            type: object
                            description: Present only while award requests are enabled.
                              `awaiting_approval` counts how many of the caller's
                              OWN submitted nominations are still waiting on a decision
                              (pending or under review) — it is a subset of `submitted`.
                            properties:
                              submitted:
                                type: integer
                                example: 5
                              awaiting_approval:
                                type: integer
                                example: 2
                      people_to_recognize:
                        type: array
                        description: |-
                          **Givers only** — present when `viewer.can_give_recognition` is true, absent otherwise (the web strip is gated on the same rule, and a chip whose give would be refused is worse than no chip).
                          The "People to recognize" nudge strip: up to 8 colleagues this caller is most likely to want to recognize next, from the same query object as the web strip (`Recognition::SuggestedRecipientsQuery`).
                          **The order IS the signal — render it as given, never re-sort.** Ranking: people the caller recently shared SHIFTS with (most shifts first — a collaboration signal a standalone recognition tool can't see), then their direct reports, then same-department colleagues to fill the strip out. Direct reports are deliberately eligible for the shift tier too; recognizing your own report is a primary use case.
                          Tapping a chip should open the give composer with that person prefilled. An empty array means "nobody to suggest" (a caller with no shared shifts, no reports and no department) — render no strip, not an empty one.
                          The strip's trailing "Someone else" affordance (open the composer with no recipient) is client chrome and carries no row here — do not expect one.
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          - type: object
                            properties:
                              first_name:
                                type: string
                                description: The compact chip label — the strip shows
                                  a face and a first name, not a full name. Honors
                                  a preferred name, and falls back to the full display
                                  name rather than ever being blank.
                                example: Maya
                              department:
                                type: string
                                nullable: true
                                description: The chip's secondary line; null when
                                  the person has no department.
                                example: Store Operations
                              reason:
                                type: string
                                nullable: true
                                description: Why this person is suggested, as the
                                  strip's subtext. Only the shift-coworker tier carries
                                  one today; null for a direct-report or department
                                  fill suggestion.
                                example: Worked 3 shifts together
                              work_anniversary:
                                type: boolean
                                description: TODAY is this person's work anniversary
                                  — the highlight state (the web strip tints the chip
                                  and appends "anniversary"). Recognize-them-now,
                                  not a date to print.
                                example: false
                      pending_approvals:
                        type: object
                        description: Reviewers only. Recognition waiting on THIS viewer's
                          decision. The same reviewer rule and scopes as the pending-approvals
                          page, so this count always equals the queue it links to.
                        properties:
                          total:
                            type: integer
                            example: 3
                          posts:
                            type: integer
                            example: 2
                          nominations:
                            type: integer
                            example: 1
                          oldest_pending_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the longest-waiting item arrived; null
                              when the queue is empty.
                      recognition:
                        type: array
                        description: The merged activity stream — public awards plus
                          the posts this viewer may see, newest first (max 5).
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - award
                              - recognition_post
                              example: award
                            id:
                              type: integer
                              example: 9012
                            title:
                              type: string
                              nullable: true
                              description: The award's title; null for a peer shout-out.
                              example: Above and Beyond
                            message:
                              type: string
                              nullable: true
                              example: Stayed late three nights to get the store reset
                                done.
                            points:
                              type: integer
                              description: Value in reward points. Awards store dollars
                                and are converted here; peer posts carry points directly.
                              example: 250
                            recipient:
                              "$ref": "#/components/schemas/RecognitionPerson"
                            group_recipients:
                              type: array
                              nullable: true
                              description: Present ONLY on a collapsed group give
                                — every recipient of the one recognition, so the client
                                can render "X and N others". Null otherwise.
                              items:
                                "$ref": "#/components/schemas/RecognitionPerson"
                            giver:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                              description: Null when the recognition is anonymous.
                                An automated lifecycle award reports `System (Automated)`
                                with a null id.
                            anonymous:
                              type: boolean
                              example: false
                            program:
                              type: string
                              nullable: true
                              example: Spot Awards
                            category:
                              type: string
                              nullable: true
                              example: Customer Focus
                            company_value:
                              type: string
                              nullable: true
                              example: Teamwork
                            occurred_at:
                              type: string
                              format: date-time
                      my_awards:
                        type: array
                        description: |-
                          Awards the caller received, newest first (max 5). Finalized only — revoked and expired awards are excluded, so this list always equals `stats.recognition_received.awards` (up to the cap).
                          **Page the rest with `GET /recognitions/my_recognition?tab=awards`, not `?tab=received`.** This card is an AWARDS-only preview; `received` merges peer shout-outs in, so sending its "View all" there shows rows the card never held. It also means the figure to render beside this card is `stats.recognition_received.awards` — NOT `stats.recognition_received.total`, which is awards + shout-outs. A recipient with shout-outs and no awards legitimately has an empty `my_awards`, a non-zero `total`, and nothing to "view all" of.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 771
                            title:
                              type: string
                              example: Customer Hero
                            message:
                              type: string
                              nullable: true
                              example: Turned a churn risk into a multi-year renewal.
                            points:
                              type: integer
                              example: 250
                            giver:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                              description: Null when the award was given anonymously.
                            program:
                              type: string
                              nullable: true
                              example: Excellence Awards
                            category:
                              type: string
                              nullable: true
                              example: Leadership
                            awarded_at:
                              type: string
                              format: date-time
                      my_nominations:
                        type: array
                        description: Present only while award requests are enabled.
                          Nominations the caller submitted, newest first (max 5).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 335
                            title:
                              type: string
                              example: Renewal save of the quarter
                            points:
                              type: integer
                              description: What the nomination is worth right now,
                                in reward POINTS — the approved figure once a reviewer
                                has set one, the requested figure until then. 0 when
                                the nomination was filed without a value.
                              example: 250
                            requested_points:
                              type: integer
                              nullable: true
                              description: The value the nominator asked for, in reward
                                points. Null (not 0) when none was specified.
                              example: 250
                            approved_points:
                              type: integer
                              nullable: true
                              description: The value a reviewer granted, in reward
                                points. Null until the nomination is approved.
                              example: 250
                            nominee:
                              "$ref": "#/components/schemas/RecognitionPerson"
                            program:
                              type: string
                              nullable: true
                              example: Excellence Awards
                            category:
                              type: string
                              nullable: true
                              example: Outstanding Performance
                            status:
                              type: string
                              enum:
                              - pending
                              - under_review
                              - approved
                              - rejected
                              - cancelled
                              example: pending
                            status_label:
                              type: string
                              description: The humanized status the web row prints.
                              example: Pending
                            awaiting_approval:
                              type: boolean
                              description: True while the status is pending or under
                                review.
                              example: true
                            submitted_at:
                              type: string
                              format: date-time
                              nullable: true
                      trending_recognition:
                        type: array
                        description: 'The most-engaged PUBLIC recognition of the last
                          24 hours (max 3). Same row shape as `recognition`, plus
                          the engagement counts the web card prints. Deliberately
                          public-only: this is org-wide social proof, so it must not
                          surface a department/team/private recognition.'
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              example: recognition_post
                            id:
                              type: integer
                              example: 4471
                            title:
                              type: string
                              nullable: true
                              description: The award's title; null for a peer shout-out.
                              example: Above and Beyond
                            message:
                              type: string
                              nullable: true
                            points:
                              type: integer
                              example: 25
                            recipient:
                              "$ref": "#/components/schemas/RecognitionPerson"
                            group_recipients:
                              type: array
                              nullable: true
                              description: Present ONLY on a collapsed group give
                                — every recipient of the one recognition. Null otherwise.
                              items:
                                "$ref": "#/components/schemas/RecognitionPerson"
                            giver:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                            anonymous:
                              type: boolean
                              example: false
                            program:
                              type: string
                              nullable: true
                              example: Spot Awards
                            category:
                              type: string
                              nullable: true
                              example: Customer Focus
                            company_value:
                              type: string
                              nullable: true
                              example: Teamwork
                            reactions_count:
                              type: integer
                              example: 14
                            comments_count:
                              type: integer
                              example: 3
                            occurred_at:
                              type: string
                              format: date-time
                      active_programs:
                        type: array
                        description: Present only while award requests are enabled.
                          The programs a nomination can actually be filed under (max
                          3). Milestone programs are excluded — anniversaries and
                          birthdays are granted automatically and are never nominated.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 12
                            name:
                              type: string
                              example: Spot Awards
                            description:
                              type: string
                              nullable: true
                              example: Peer-to-peer recognition for great work, any
                                time.
                            program_type:
                              type: string
                              enum:
                              - peer_to_peer
                              - manager_to_employee
                              - achievement_based
                              example: peer_to_peer
                            icon:
                              type: string
                              description: Font Awesome class derived from the program
                                type — the same glyph the web card shows. Programs
                                carry no icon of their own.
                              example: fas fa-users
                            can_nominate:
                              type: boolean
                              description: Server-side eligibility for THIS caller.
                                False for a manager-only program a non-manager is
                                viewing, or one whose criteria they don't meet — don't
                                open the nominate form; the POST would be rejected.
                              example: true
                      top_recipients_this_month:
                        type: array
                        description: The month's most-recognized people (max 5), counting
                          BOTH awards and peer shout-outs — recognition is recorded
                          on two tables and a single-table leaderboard undercounts
                          it.
                        items:
                          type: object
                          properties:
                            rank:
                              type: integer
                              description: 1-based position in this list.
                              example: 1
                            user:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                              description: Null when the recipient has since left
                                the business. Their count still stands, so `name`
                                is always sent.
                            name:
                              type: string
                              example: Patrick Smith
                            count:
                              type: integer
                              example: 4
                      running_program:
                        type: object
                        nullable: true
                        description: Present only while award cycles are enabled.
                          The time-boxed cycle currently accepting nominations — null
                          when none is open, in which case render nothing rather than
                          an empty card. When several are open, the one closing soonest
                          wins.
                        properties:
                          id:
                            type: integer
                            example: 7
                          title:
                            type: string
                            example: Employee of the Quarter — Q3
                          description:
                            type: string
                            nullable: true
                            example: Recognize a standout performer for the quarter.
                          icon:
                            type: string
                            description: Cycles carry no icon; the web banner uses
                              the trophy.
                            example: fas fa-trophy
                          days_left:
                            type: integer
                            description: Whole days until the window closes, floored
                              at 0 (0 = closes today).
                            example: 5
                          closes_at:
                            type: string
                            format: date-time
                          total_nominations:
                            type: integer
                            description: Nominations pooled in this cycle so far,
                              from everyone.
                            example: 14
                          can_nominate:
                            type: boolean
                            description: 'Both halves of the server''s own gate: the
                              cycle is still accepting AND this viewer may give recognition.'
                            example: true
                          program_id:
                            type: integer
                            example: 12
                          program_name:
                            type: string
                            example: Mango Champions
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/employee_suggestions":
    get:
      tags:
      - Recognitions
      summary: Employee suggestions for giving recognition
      description: |
        The colleagues this caller may pick as a recognition **recipient** — the
        native-client mirror of the web recipient typeahead
        (`GET /recognition/employee_suggestions`), which backs the give composer,
        both nominate forms and the manager Quick Award.

        Both surfaces read ONE query object
        (`Recognition::EmployeeSuggestionsQuery`), so who is suggestible, what a
        search matches and the order rows come back in are the same code. A
        colleague findable in the app is findable here, and vice versa.

        **Givers only.** This is the one endpoint in the Recognitions API gated on
        giving access, because a recipient roster is only ever read in order to
        give. A tenant that switches peer recognition off leaves giving to admins
        and managers, and the submit path enforces exactly the same rule — so a
        caller is never handed a roster whose give would be refused. Everyone else
        gets 403 `giving_not_allowed` rather than a browsable directory of their
        colleagues.

        **Who is listed:** active members of *this* business only. Never the
        caller (the submit path strips them out of the recipient set anyway),
        never a member the admin deactivated, never a service / AI-agent
        principal, never another tenant's user.

        **Search (`q`)** matches, case-insensitively and on any substring, against
        the `name` column, `first_name`, `last_name`, `preferred_name`, `email`,
        and the `"<first> <last>"` / `"<preferred> <last>"` forms. LIKE wildcards
        (`%`, `_`) are treated as literal characters. A blank `q` returns the
        whole roster alphabetically, which is what a picker opens with.

        **Suggestions, not a ranked feed.** The work-context nudge ("People to
        recognize" — recent shift coworkers, then direct reports, then department)
        is a different, un-paginated list and ships on
        `GET /recognitions/dashboard`; it is not duplicated here.

        `scope=direct_reports` applies the Quick Award narrowing: for a
        **non-admin** it restricts the roster to their own direct reports, which is
        the recipient rule the Quick Award submit path enforces. An admin may award
        anyone, so nothing is narrowed for them — read `meta.direct_reports_only`
        (not the `scope` you sent) to decide "your team only" copy.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Search term. Matched against name, first/last/preferred name,
          email and the two "<first> <last>" / "<preferred> <last>" forms. Blank means
          the whole roster.
        example: maya
      - name: scope
        in: query
        required: false
        schema:
          type: string
          enum:
          - all
          - direct_reports
          default: all
        description: "`direct_reports` narrows a non-admin caller to their own reports
          (the Quick Award recipient rule). An unrecognized value falls back to `all`
          rather than erroring."
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based page number.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page, clamped to 100. Defaults to 25, matching the web
          picker, so the two surfaces page identically.
      responses:
        '200':
          description: Suggestions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - employees
                - meta
                properties:
                  employees:
                    type: array
                    description: Suggestible colleagues for this page, ordered by
                      name (with id as a stable tiebreak, so no one appears on two
                      pages).
                    items:
                      "$ref": "#/components/schemas/RecognitionEmployeeSuggestion"
                  meta:
                    "$ref": "#/components/schemas/RecognitionEmployeeSuggestionsMeta"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked notification badge count.
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: |
            Either the Recognitions app isn't accessible to this caller
            (`access_denied`), or the caller may not give recognition
            (`giving_not_allowed`) — the same rule the submit path enforces. No
            roster is included in a refusal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                        - giving_not_allowed
                      message:
                        type: string
  "/recognitions/quick_award":
    get:
      tags:
      - Recognitions
      summary: Quick Award form options
      description: |
        **Everything the Quick Award form renders from**, in one call — the native
        mirror of the web "Give an Instant Award" screen (Team ▸ Quick Award) and
        of the native design's **Team ▸ + ▸ Quick Award** sheet.

        A Quick Award is a manager awarding points to a team member **instantly,
        with no approval step** — the counterpart to a nomination, which goes
        through review. It draws against the manager's giving budget when the
        tenant configures one.

        Both this endpoint and the web page read one shared object
        (`Recognition::QuickAwardOptions`), and the POST below resolves its
        default program through the same object — so what is **offered** here is
        what that screen offers, and what the server will **accept**.

        ### Who may call it

        **Reviewers only, in a tenant that has Quick Awards on** — a Recognitions
        admin, or anyone with direct reports. That is exactly the pair
        `GET /recognitions/config` reports as `permissions.can_quick_award`, so
        read that flag to decide whether to show the affordance at all rather than
        probing this endpoint. The two refusals are separate codes on purpose:
        `feature_disabled` means the org doesn't do Quick Awards (hide it),
        `forbidden` means this person isn't a reviewer (it isn't theirs).

        Per-**recipient** authority is a different question, answered by
        `recipient_scope`: an admin may award anyone, a manager only their own
        direct reports — which is the rule the POST enforces on the row. Call
        `GET /recognitions/employee_suggestions?scope={recipient_scope}` to fill
        the picker and it can never offer somebody the POST would reject.

        ### Every figure is in POINTS

        Reward points are the program-wide unit, shared with peer gives and the
        Company Store. The columns store dollars (1 pt = 1¢) and the server
        converts on the way in and out, so a client never handles dollars —
        `amount.points_per_dollar` is there for the rare screen that shows a
        currency figure.

        ### Composing the form from this payload

        * **`amount`** — the input's floor, ceiling and pre-filled value, plus
          `presets`, the web's own one-tap ladder already filtered to the tenant's
          cap. Never widen the input past `max_points`; the POST refuses it.
        * **`limits`** — what the two text fields will actually accept. Both are
          optional (the server substitutes a default when either is blank), but
          `message_min` is the one that surprises callers: a note that IS sent has
          to clear 10 characters, or the POST answers 422 `invalid`.
        * **`programs`** — active, in-window programs by name, each with only its
          **active** categories. A category carries its own point range
          (`min_points` / `max_points` / `default_points`, `null` meaning
          unbounded on that side) and a program may carry
          `per_award_limit_points`, its own ceiling on a single award. Bound the
          amount input by the tightest of the three.
        * **`require_category`** — whether the category field is mandatory. Render
          the asterisk from this; the server enforces it.
        * **`default_program_id`** — what "Default Program" resolves to, and the
          program the POST funds the award from when `program_id` is omitted. The
          `budget` below is that program's budget, so the two agree.
        * **`budget`** — the manager's remaining giving budget, every figure in
          points. `{ "enabled": false }` for most tenants: businesses without
          group budgets are unaffected. `blocked` is the web submit button's own
          disabled condition — disable Give on it rather than re-deriving
          "exhausted or nothing left".
        * **`recent_awards`** — this manager's last few awards in this tenant, the
          web sidebar's confirmation list. Deliberately smaller than a feed card;
          the full card is one request away at `GET /recognitions/awards/{id}`.

        ### Cost

        Flat. Nothing costs a query per program or per category, so this is safe
        to call every time the sheet opens.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The form's options
          content:
            application/json:
              schema:
                type: object
                required:
                - quick_award
                properties:
                  quick_award:
                    "$ref": "#/components/schemas/RecognitionQuickAwardForm"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app isn't accessible to this caller (`access_denied`),
            the tenant has Quick Awards switched off (`feature_disabled`), or the
            caller is not a reviewer (`forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardRefusal"
    post:
      tags:
      - Recognitions
      summary: Give a Quick Award
      description: |
        **Award points to a team member instantly, with no approval step.** The
        native mirror of the web "Give an Instant Award" submit
        (`POST /recognition/quick_award`) and of the mobile Quick Award form.

        Runs `Recognition::QuickAwardService`, the canonical creator every non-web
        quick-award surface runs, so the direct-report recipient rule, the amount
        bounds, the anti-gaming guard, the program per-award cap, the category
        requirement and the per-manager group budget are the same code everywhere.
        The award is created **active** — it is live the moment this returns 201,
        the points are credited to the recipient's Company Store balance, and the
        recipient is notified.

        Gated identically to the GET above. Fill every picker from it.

        ### The fields

        `recipient_id` and `amount` are the only required ones — that is the
        minimum the native sheet collects. Everything else has a documented
        server-side default:

        * **`amount`** is in **POINTS**, within `amount.min_points` ..
          `amount.max_points`, and also within the chosen program's
          `per_award_limit_points` and the chosen category's range when either is
          set.
        * **`program_id`** omitted funds the award from `default_program_id`. A
          `program_id` that is not an active program in this tenant is refused
          with `invalid_program` — it is never silently swapped for the default,
          which would charge a program the caller did not choose.
        * **`category_id`** must belong to the chosen program — a category id from
          a *different* program is silently ignored rather than refused, matching
          the web form, where a category can only be picked after its program.
          Required when `require_category` is true.
        * **`title`** blank becomes `"Quick Award from {giver name}"`.
        * **`message`** blank becomes `"Great work! Keep it up."`. A value that
          IS sent must clear `limits.message_min` (10 characters).
        * **`is_public`** **defaults to `true`**, because the web checkbox is
          pre-checked — send `false` explicitly to keep the award off the
          recognition feed and out of any connected Slack/Teams channel.
        * **`anniversary_years`** is what the anniversary roster's **Recognize**
          action adds, and the ONLY thing that makes this award count as that
          person's work-anniversary recognition — send the row's own `years`.
          Omit it for an ordinary spot award. See the field description for what
          it records.

        ### The response

        `award` is the same **feed card** `GET /recognitions/feed` and
        `GET /recognitions/awards/{id}` return, so the card a client inserts
        optimistically is the card it gets back on the next refresh. `message` is
        the web flash word for word — show it as-is. `budget` is the state
        **after** the spend, so a budget header refreshes without a second call.

        Unlike a peer give, a Quick Award never lands in a pending state: there is
        no moderation hold and no approval routing, so a 201 always means the
        award is live.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionQuickAwardRequest"
            examples:
              minimal:
                summary: The native sheet's minimum — a person and an amount
                value:
                  recipient_id: 412
                  amount: 500
              full:
                summary: The web form, every field
                value:
                  recipient_id: 412
                  program_id: 7
                  category_id: 31
                  amount: 2500
                  title: Shipped the migration
                  message: You carried the migration all weekend — thank you.
                  is_public: true
              private:
                summary: Keep it off the feed
                value:
                  recipient_id: 412
                  amount: 500
                  is_public: false
              anniversary:
                summary: The anniversary roster's Recognize action — records the recognition
                value:
                  recipient_id: 412
                  amount: 500
                  title: 10 Year Work Anniversary
                  anniversary_years: 10
      responses:
        '201':
          description: The award is live, the points are credited
          content:
            application/json:
              schema:
                type: object
                required:
                - award
                - message
                properties:
                  award:
                    "$ref": "#/components/schemas/RecognitionQuickAwardCard"
                  message:
                    type: string
                    description: The web flash, word for word. Safe to show as-is;
                      a 201 always means the award is live.
                    example: Successfully awarded Priya Nair with 500 points! They've
                      been credited to their account.
                  budget:
                    "$ref": "#/components/schemas/RecognitionQuickAwardBudget"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied`, `feature_disabled` or `forbidden` — the same
            three refusals, with the same wording, as the GET above. A rule that guarded
            only the read would be a rule a direct POST walked past."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardRefusal"
        '422':
          description: |
            The award was refused and **nothing was written**. `error.code` says
            which rule, so a client can highlight the offending field instead of
            showing a generic banner:

            | code | the field to fix |
            |---|---|
            | `invalid_recipient` | `recipient_id` — no such person in this tenant |
            | `recipient_not_permitted` | `recipient_id` — not one of your direct reports (an admin may award anyone) |
            | `invalid_amount` | `amount` — zero or negative |
            | `amount_over_limit` | `amount` — above the tenant cap, the program's `per_award_limit_points`, or the category's range. `details` carries the bounds. |
            | `category_required` | `category_id` — this tenant requires one |
            | `governance_blocked` | none — the tenant's monthly give cap or duplicate-recipient cooldown. `message` names the limit and when it lifts; show it verbatim. |
            | `budget_exceeded` | `amount` — the manager's giving budget can't cover it. `budget` reports what's left. |
            | `invalid_program` | `program_id` — you named a program that isn't an active one in this tenant. Re-fetch the picker from `GET /recognitions/quick_award`. Omit `program_id` to award from the default program instead. |
            | `no_program` | none — the tenant has no active recognition program. An admin has to create one. |
            | `invalid` | a model validation, most often `message` below `limits.message_min`. `message` carries the validation's own wording. |

            Note `recipient_not_permitted` is **422, not 403**: the endpoint *is*
            for this caller, it is the recipient field that is wrong — highlight
            the picker rather than hiding the screen.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardError"
        '500':
          description: "`internal_error` — an unexpected failure while creating the
            award. Distinguished from the 422s so a client retries rather than telling
            the manager their input was wrong."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardError"
  "/recognitions/my_recognition":
    get:
      tags:
      - Recognitions
      summary: My Recognition — the caller's own all-time record
      description: |
        The native-client mirror of the web **My Recognition** page
        (`/recognition/my_recognition`). Both surfaces read the same query object
        (`Recognition::MyRecognitionQuery`), so the rows, the totals and the
        reward-points wallet cannot drift between them.

        **Always self-scoped.** There is no `user_id` parameter and one sent
        anyway is ignored — this endpoint can only ever return the token holder's
        own recognition. The manager-tier reads for someone else's history live on
        the separate Recognition Connect integration API
        (`/recognition/user_awards`, `/recognition/user_nominations`), which carry
        their own authorization.

        **One list per tab.** Recognition lives on two tables — program/manager
        awards and peer shout-outs. The web page renders them as stacked sections
        with independent cursors; this endpoint merges them into ONE newest-first
        list behind a single cursor, so the mobile screen is one scrolling card
        list. Every row carries `type` (`award` / `recognition_post` /
        `nomination`) and shares an envelope, so one client component can render
        them all. `awards` and `nominations` are single-type tabs — `awards` is
        `received` with the shout-outs removed, and is what the dashboard's
        "My awards" card pages into.

        **Everything is all-time**, because this is the lifetime history hub: the
        headline totals, the tab badges and the tab lists are the same numbers and
        agree with one another. `/recognition/wrapped` is the separate
        current-year recap, and the two intentionally differ for anyone with
        prior-year activity.

        **Where the numbers deliberately disagree.** `summary.total_given` counts
        FINALIZED gives only, so it matches the web tab badge and My Wrapped. The
        Given LIST additionally carries gives still awaiting approval or
        moderation — flagged `pending: true` with an `approval_state` — so a
        just-submitted give stays visible. That is why `meta.total_count` on the
        Given tab equals `summary.total_given + summary.pending_given` and can
        exceed `total_given`. On Received and Nominations, `meta.total_count`
        equals the matching `summary` total exactly.

        Anonymous recognition never names the giver (`giver: null`,
        `anonymous: true`), and automated lifecycle awards (anniversaries,
        birthdays) present as `System (Automated)` with a null id rather than
        leaking the system principal.

        **Every row carries the same `permissions` block the feed card and the
        detail screen ship**, so one card component renders the same ⋯ menu
        wherever it meets a recognition and never an affordance the server would
        refuse. Each flag is the canonical predicate the write endpoint itself
        enforces, so this is the one part of the payload where an admin's answer
        differs from an employee's. Four things behave differently here than on
        the feed, all of them because of what this screen serves:

        * **`can_boost` is always `false`.** Every row is the caller's own give
          or their own receipt, and boosting either is refused — which is also
          why this endpoint reports no `boost` block at all.
        * **`can_comment` / `can_react` are `false` on a pending give**, not just
          when the tenant switched them off. A recognition still awaiting
          approval is not live, so neither request would be accepted — matching
          the zero `engagement` the same row reports. Feed rows are always live,
          so the feed can report the tenant switch alone.
        * **`can_delete` is `true` on a pending give the caller authored.** An
          author may withdraw their own in-flight give; `can_edit` is `false` on
          the same row, because only an active recognition is editable. The two
          are not one gate.
        * **`can_delete` is withheld on award rows** (the same card-level choice
          the feed makes — an award's delete is the admin revoke, confirmed on
          the detail screen). Read `can_delete` from
          `GET /recognitions/awards/{id}` before hiding a revoke entry.
          `can_edit` is *not* withheld: a moderator may fix an award's message.

        A `nomination` row carries the block too, with every flag `false` — a
        nomination is not a recognition yet, and no edit, withdraw, share,
        comment or reaction endpoint accepts one. The approve/reject decisions
        live on the reviewer's own queue, never on the nominator's history row.
      security:
      - BearerAuth: []
      parameters:
      - name: tab
        in: query
        required: false
        schema:
          type: string
          enum:
          - received
          - given
          - nominations
          - awards
          default: received
        description: |-
          Which record to page over. `nominations` requires award requests (Model A) to be enabled for the tenant; asking for it when it is off — like any unrecognized value — serves `received` rather than erroring, the same fallback the web page redirects that deep-link to. Read `meta.tab` for what was actually served and `meta.available_tabs` for what this tenant offers.
          `awards` is `received` with the peer shout-outs removed — every row is an `award`. It is the destination for the dashboard "My awards" card's own "View all": that card is the top 5 of THIS list, so the rows match and `meta.total_count` here equals `stats.recognition_received.awards` there. `summary` is unchanged on this tab (still the four all-time tiles), so a client can render the tile row from any tab.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based. A missing, zero, negative or non-numeric value is page
          1.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        description: Clamped to 1..50.
      responses:
        '200':
          description: My Recognition retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - reward_points
                - summary
                - meta
                properties:
                  items:
                    type: array
                    description: The requested page, newest first. Mixed row types
                      on Received/Given; `nomination` rows only on Nominations. Key
                      off `type` — the award-only and post-only fields are present
                      as null on the other types so the envelope never changes shape.
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum:
                          - award
                          - recognition_post
                          - nomination
                          example: award
                        id:
                          type: integer
                          example: 481
                        title:
                          type: string
                          nullable: true
                          description: The award or nomination title. Null for a peer
                            shout-out, which has none.
                          example: Above & Beyond
                        message:
                          type: string
                          nullable: true
                          description: The award description, the post body, or the
                            nomination description.
                          example: Nailed the migration under real pressure.
                        points:
                          type: integer
                          description: Reward points, on EVERY row type — the figure
                            a client's shared recognition card renders. An award stores
                            dollars and converts (1 pt = 1¢); a peer post stores points
                            directly; a nomination converts its approved value, or
                            its requested value until one is approved. 0 when the
                            row carries no value.
                          example: 250
                        value:
                          type: string
                          nullable: true
                          description: Awards only — the stored DOLLAR figure, as
                            a decimal string. Use `points` unless you specifically
                            mean money.
                          example: '2.5'
                        display_value:
                          type: string
                          nullable: true
                          description: Awards only — the label the web row shows ("Priceless"
                            when unvalued).
                          example: 250 pts
                        recipient:
                          "$ref": "#/components/schemas/RecognitionPerson"
                        group_recipients:
                          type: array
                          nullable: true
                          description: Present only on a group give that collapsed
                            into one row, naming every recipient.
                          items:
                            "$ref": "#/components/schemas/RecognitionPerson"
                        giver:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          nullable: true
                          description: Null when the recognition is anonymous. For
                            an automated lifecycle award, `name` is "System (Automated)"
                            and `id` is null.
                        anonymous:
                          type: boolean
                          example: false
                        program:
                          type: string
                          nullable: true
                          example: Spot Awards
                        category:
                          type: string
                          nullable: true
                          example: Teamwork
                        company_value:
                          type: string
                          nullable: true
                          example: Ownership
                        visibility:
                          type: string
                          nullable: true
                          description: Posts only — public / department / team / private.
                          example: public
                        tags:
                          type: array
                          description: Posts only. Always an array (never null).
                          items:
                            type: string
                          example:
                          - teamwork
                        photo_url:
                          type: string
                          nullable: true
                          description: Absolute URL of the photo attached to this
                            one recognition, already a processed rendition.
                        award_art_url:
                          type: string
                          nullable: true
                          description: Absolute URL of the Asset Library award card
                            this recognition was given with.
                        certificate_url:
                          type: string
                          nullable: true
                          description: Awards only — the printable certificate the
                            web row links to.
                          example: "/recognition/awards/481/card"
                        engagement:
                          type: object
                          description: Reaction and comment counts. Always zero for
                            a pending give — nothing can react to a recognition that
                            is not live yet.
                          properties:
                            reactions_count:
                              type: integer
                              example: 4
                            comments_count:
                              type: integer
                              example: 1
                        permissions:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPermissions"
                          description: What this caller may do with THIS row — the
                            same block the feed card and the detail screen ship, and
                            the only part of the payload where an admin's answer differs
                            from an employee's. Present on all three row types. `can_boost`
                            is always false here, `can_comment` / `can_react` are
                            false on a pending give, `can_delete` is withheld on award
                            rows, and every flag is false on a `nomination` — see
                            the endpoint description for why.
                        pending:
                          type: boolean
                          description: A give still awaiting approval or moderation.
                            Present on the Given tab only; excluded from `summary.total_given`
                            but counted in `meta.total_count`.
                          example: false
                        approval_state:
                          type: string
                          nullable: true
                          enum:
                          - posting
                          - pending_approval
                          - pending_review
                          -
                          description: Why a give is not live yet. `posting` is the
                            automated content check (the normal path, no human is
                            holding it); the other two need a reviewer to act.
                        status:
                          type: string
                          nullable: true
                          description: Nominations only.
                          example: pending
                        display_status:
                          type: string
                          nullable: true
                          description: Nominations only — the humanized status the
                            web badge shows.
                          example: Pending
                        status_color:
                          type: string
                          nullable: true
                          description: Nominations only — the hex the web badge is
                            painted with.
                          example: "#ffc107"
                        approval_level:
                          type: integer
                          nullable: true
                          description: Nominations only — which approval step is pending
                            ("Level 1 of 2").
                          example: 1
                        approval_levels_required:
                          type: integer
                          nullable: true
                          example: 2
                        nominee:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          nullable: true
                          description: Nominations only.
                        reviewer:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          nullable: true
                          description: Nominations only — null while unassigned.
                        requested_value:
                          type: string
                          nullable: true
                          description: Nominations only — the dollar value asked for,
                            as a decimal string. Use `requested_points` (or `points`)
                            unless you specifically mean money.
                          example: '2.5'
                        approved_value:
                          type: string
                          nullable: true
                          description: Nominations only — the dollar value granted,
                            null until approved.
                        requested_points:
                          type: integer
                          nullable: true
                          description: Nominations only — `requested_value` in reward
                            POINTS, the unit the row's `points` and every other figure
                            in this API are denominated in. Null (not 0) when no value
                            was asked for, so "not specified" is distinguishable from
                            "worth nothing".
                          example: 250
                        approved_points:
                          type: integer
                          nullable: true
                          description: Nominations only — `approved_value` in reward
                            points. Null until the nomination is approved.
                          example: 250
                        reviewer_notes:
                          type: string
                          nullable: true
                          description: Nominations only — the reviewer's note, including
                            a rejection reason.
                        justification:
                          type: string
                          nullable: true
                          description: Nominations only.
                        occurred_at:
                          type: string
                          format: date-time
                          description: When the recognition happened — awarded_at,
                            published_at (created_at for a give not yet published),
                            or the nomination's submitted_at. The field the list is
                            sorted by.
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Nominations only.
                  reward_points:
                    type: object
                    description: 'The caller''s reward-points wallet. Reported whether
                      or not the Company Store is reachable for this user — points
                      are earned through recognition either way — so use `store_available`
                      to decide whether to offer the redeem/history affordances, not
                      whether to show the balance. Read-only: a GET never creates
                      the wallet row.'
                    properties:
                      store_available:
                        type: boolean
                        description: Whether the Company Store app is enabled for
                          the tenant AND this user is inside its audience.
                        example: true
                      points_balance:
                        type: integer
                        description: Points the caller can spend now.
                        example: 450
                      pending_points:
                        type: integer
                        description: Earned but not yet settled (recognition still
                          awaiting approval).
                        example: 25
                      lifetime_points_earned:
                        type: integer
                        example: 1200
                      lifetime_points_spent:
                        type: integer
                        example: 750
                      expiring_points:
                        type: integer
                        description: Points that will have aged past the tenant's
                          expiry window by the time the warning notification fires,
                          so this number and that notification agree. 0 when the tenant
                          has not configured expiry.
                        example: 150
                      points_expiry_months:
                        type: integer
                        description: The tenant's expiry window in months; 0 means
                          points never expire.
                        example: 12
                  summary:
                    type: object
                    description: The four headline tiles / tab badges, byte-identical
                      to the web page's own, plus `pending_given` and `total_awards`.
                      All all-time, and identical on every tab.
                    properties:
                      total_received:
                        type: integer
                        description: Awards received + shout-outs received. Equals
                          the Received tab's `meta.total_count`.
                        example: 12
                      total_awards:
                        type: integer
                        description: The awards half of `total_received` — shout-outs
                          excluded. Equals the Awards tab's `meta.total_count`, and
                          the dashboard's `stats.recognition_received.awards`.
                        example: 4
                      total_given:
                        type: integer
                        description: Awards given + shout-outs authored, FINALIZED
                          only — matching the web badge and My Wrapped. Gives awaiting
                          approval are counted in `pending_given` instead.
                        example: 28
                      total_nominations:
                        type: integer
                        description: Nominations the caller submitted, every status.
                          Equals the Nominations tab's `meta.total_count`.
                        example: 5
                      pending_nominations:
                        type: integer
                        description: Of those, the ones still awaiting a decision.
                        example: 2
                      pending_given:
                        type: integer
                        description: 'Gives still awaiting approval or moderation.
                          Present in the Given LIST (flagged `pending: true`) but
                          never in `total_given`.'
                        example: 1
                  meta:
                    type: object
                    properties:
                      tab:
                        type: string
                        enum:
                        - received
                        - given
                        - nominations
                        - awards
                        description: The tab actually served — compare with your request
                          to detect a coercion.
                        example: received
                      available_tabs:
                        type: array
                        description: The tabs this tenant offers. `nominations` is
                          absent when award requests (Model A) are disabled; the other
                          three are always present.
                        items:
                          type: string
                        example:
                        - received
                        - given
                        - nominations
                        - awards
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_count:
                        type: integer
                        description: Rows in the whole list this tab pages over —
                          the real total, not the size of the page.
                        example: 12
                      total_pages:
                        type: integer
                        example: 1
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                      capabilities:
                        type: object
                        properties:
                          can_give_recognition:
                            type: boolean
                            description: Whether the caller may give peer recognition
                              at all — the SAME guard the submit path enforces. Hide
                              the Give affordance when false; the POST would be rejected.
                            example: true
                          award_requests_enabled:
                            type: boolean
                            description: Model A — gates the Nominations tab and the
                              Nominate affordance.
                            example: true
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/wrapped":
    get:
      tags:
      - Recognitions
      summary: Recognition Wrapped — the caller's personal year-in-recognition recap
      description: |
        The native-client mirror of the web **Recognition Wrapped** screen
        (`/recognition/wrapped`) — a Spotify-Wrapped-style recap of the caller's
        recognition for one calendar year: how much they gave and received, the
        company values they were celebrated for, and the people they exchanged the
        most recognition with. Both surfaces read the same service
        (`Recognition::WrappedService`) and the same year-resolution helpers, so
        the numbers and the year range cannot drift between them.

        **Always self-scoped.** There is no `user_id` parameter and one sent anyway
        is ignored — this endpoint can only ever return the token holder's own
        recap. The manager-tier reads for someone else's history live on the
        separate Recognition Connect integration API, which carries its own
        authorization.

        **Scoped to one calendar year by `created_at`.** `?year=` selects the
        recap year; a missing, non-numeric or out-of-range value serves the current
        year — the same clamp the web page applies to a hand-typed year. Read
        `meta.year` for what was actually served and `meta.available_years` for the
        range this tenant offers (its creation year through the current year,
        newest first).

        **Empty is a state, not an error.** A caller with no recognition this year
        gets `has_data: false` with zeroed tiles and empty lists (HTTP 200) — the
        client renders the "no recognition yet" empty state rather than treating it
        as a failure.

        The `top_champions` / `people_you_lifted_up` lists are capped at 3 and
        exclude the caller's own self-recognition; `celebrated_for` is capped at 3,
        most-tagged first.
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: query
        required: false
        schema:
          type: integer
        description: The recap year. A missing, non-numeric or out-of-range value
          serves the current year (see `meta.year` / `meta.available_years`).
      responses:
        '200':
          description: The recap retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - has_data
                - received
                - given
                - points_earned
                - celebrated_for
                - top_champions
                - people_you_lifted_up
                - highlights
                - meta
                properties:
                  has_data:
                    type: boolean
                    description: Whether the caller gave or received anything this
                      year. When false the tiles are zero and the lists empty — render
                      the empty state.
                    example: true
                  received:
                    type: object
                    description: The "Recognition received" tile — total and its shout-out/award
                      split.
                    required:
                    - total
                    - shout_outs
                    - awards
                    properties:
                      total:
                        type: integer
                        example: 12
                      shout_outs:
                        type: integer
                        description: Peer recognition posts received.
                        example: 8
                      awards:
                        type: integer
                        description: Program/manager awards received.
                        example: 4
                  given:
                    type: object
                    description: The "Recognition given" tile — total and its shout-out/award
                      split.
                    required:
                    - total
                    - shout_outs
                    - awards
                    properties:
                      total:
                        type: integer
                        example: 5
                      shout_outs:
                        type: integer
                        example: 3
                      awards:
                        type: integer
                        example: 2
                  points_earned:
                    type: integer
                    description: Reward points earned from peer shout-outs this year
                      (awards are not counted here).
                    example: 250
                  celebrated_for:
                    type: array
                    description: Up to 3 company values the caller was recognized
                      for, most-tagged first. `count` is the number of recognitions
                      tagged to that value — the web card's progress bars are relative
                      to the first (largest) row.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 17
                        name:
                          type: string
                          example: Teamwork
                        count:
                          type: integer
                          example: 6
                  top_champions:
                    type: array
                    description: Up to 3 people who recognized the caller most this
                      year. Never includes the caller.
                    items:
                      "$ref": "#/components/schemas/WrappedPersonCount"
                  people_you_lifted_up:
                    type: array
                    description: Up to 3 people the caller recognized most this year.
                    items:
                      "$ref": "#/components/schemas/WrappedPersonCount"
                  highlights:
                    type: object
                    description: The web "Highlights" card's lines. The first two
                      are null when there's nothing to say.
                    required:
                    - busiest_month
                    - first_received_on
                    - given_count
                    - received_count
                    properties:
                      busiest_month:
                        type: string
                        nullable: true
                        description: The calendar month the caller received the most
                          (English month name), or null if nothing was received.
                        example: March
                      first_received_on:
                        type: string
                        format: date
                        nullable: true
                        description: The date of the caller's first recognition this
                          year (ISO 8601), or null.
                        example: '2026-03-04'
                      given_count:
                        type: integer
                        description: Echoes `given.total`, so the card is self-contained.
                        example: 5
                      received_count:
                        type: integer
                        description: Echoes `received.total`.
                        example: 12
                  meta:
                    type: object
                    required:
                    - year
                    - available_years
                    - capabilities
                    properties:
                      year:
                        type: integer
                        description: The year actually served (after clamping).
                        example: 2026
                      available_years:
                        type: array
                        description: The years a recap can be requested for — the
                          tenant's creation year through the current year, newest
                          first.
                        items:
                          type: integer
                        example:
                        - 2026
                        - 2025
                        - 2024
                      capabilities:
                        type: object
                        properties:
                          can_give_recognition:
                            type: boolean
                            description: Whether the caller may give peer recognition
                              — the SAME guard the submit path enforces. Hide the
                              "Give Recognition" affordance when false.
                            example: true
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/feed":
    get:
      tags:
      - Recognitions
      summary: Recognition feed (filtered, paginated)
      description: |
        The browsable recognition feed — the native-client mirror of the web
        feed page. Both surfaces read the same query object, so the rows, their
        order, the visibility rules and the filters are identical.

        **What the caller sees** is decided entirely by visibility, never by a
        role flag:

        * the tenant's **public awards** (non-public awards are never returned), and
        * **recognition posts** that are public, addressed to or written by the
          caller, `department`-scoped and authored by someone in the caller's
          department, or `team`-scoped and authored by a teammate.
        * `private` posts are returned **only** to their author and recipient.

        Only recognition from the **last 30 days** is in scope — the feed is
        "what's happening", not an archive.

        A **group give** (one recognition sent to several people) collapses into
        a single item whose `group_recipients` names everyone, rather than
        repeating a near-identical card per recipient.

        **Roles are emergent.** An employee, a manager and an admin all run the
        same query; they differ only in what it returns and in each item's
        `permissions` block. `my_team` resolves to a manager's direct reports,
        and to an employee's peers (their manager's reports).
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: |
          Which slice of the feed to return. Same five the web page offers;
          an unrecognised value falls back to `all` rather than erroring.
            * `all`           — awards and posts together (default)
            * `awards`        — public awards only
            * `posts`         — peer recognition posts only
            * `my_team`       — recognition given to or by the caller's team.
                                Direct reports (+ self) for a manager; the
                                caller's manager's reports (+ that manager)
                                for an individual contributor. Falls back to
                                the unfiltered feed when the caller has
                                neither reports nor a manager.
            * `my_department` — recognition given to or by anyone in the
                                caller's department. Unfiltered when the
                                caller has no department.
        schema:
          type: string
          enum:
          - all
          - awards
          - posts
          - my_team
          - my_department
          default: all
      - name: page
        in: query
        required: false
        description: 1-based page number. Values below 1 are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page. Clamped to 50.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The requested page of the feed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionFeedItem"
                  meta:
                    "$ref": "#/components/schemas/RecognitionFeedMeta"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/leaderboard":
    get:
      tags:
      - Recognitions
      summary: Leaderboard — top recipients and top givers
      description: |
        The native-client mirror of the web **Recognition Leaderboard**
        (`/recognition/leaderboard`). Both surfaces read the same query object
        (`Recognition::LeaderboardQuery`), so the period windows, the tables the
        counts are drawn from, the category basis switch, the tie-break and the
        caller's own rank cannot drift between them.

        **Two boards, five rows each.** `top_recipients` ranks who was recognized
        most; `top_givers` ranks who recognized others most. The web page shows 20
        rows per board and this endpoint shows 5 — same query, different depth,
        reported back as `meta.limit`.

        **No role branching.** The board is business-wide: an employee, a manager
        and a recognition admin receive exactly the same rows in the same order.
        The only caller-specific parts of the payload are `my_standing`, the
        `is_me` flag on a row, and `meta.capabilities`. Access is gated the same
        way as every other endpoint in this namespace — the Recognitions app must
        be enabled for the tenant AND the caller must be inside the app's
        audience, otherwise 403 `access_denied`.

        **Recognition lives on two tables** — formal awards and peer shout-outs.
        The default "All Categories" view counts BOTH, so peer recognition is not
        undercounted. Peer shout-outs carry no category, so selecting a category
        necessarily switches the basis to awards-only:
        `meta.counting_basis` becomes `awards_only` and `meta.basis_note` carries
        the sentence to show the viewer. **Render that note** — without it a giver
        whose recognition is mostly shout-outs appears to have fallen off the
        board for no visible reason.

        **What is never counted:** revoked or expired awards and non-active posts
        (they are filtered out of every other surface, so counting them here would
        credit recognition that was taken back), another tenant's recognition, and
        — on the givers board only — anonymous gives, since naming an anonymous
        giver on a public board is exactly the disclosure they opted out of, and
        automated lifecycle awards (anniversaries, birthdays, service
        milestones), which are written by a system principal rather than by any
        person. The recipient board is unaffected by either exclusion: it hides
        who gave, not that someone was recognized, and a work-anniversary award
        is recognition its recipient genuinely received.

        **Two different meanings of "rank".** A row's `rank` is its 1-based
        POSITION in the list — what the medallion renders (gold #1, silver #2,
        bronze #3, neutral from #4 down). `my_standing.*.rank` is competition
        style against the FULL board, so ties share a number and two people tied
        for the lead are both rank 1. `my_standing.*.rank` is `null` — unranked,
        not last — when the caller has no activity in the period; render nothing
        rather than a zero row. Use `my_standing.*.in_top` to decide whether to
        pin a "You — #47" footer row, so it is never a duplicate of a row already
        drawn above.

        **Filter values are coerced, not rejected.** An unrecognized `period`
        serves `month`; a `category` slug that is unknown, deleted or deactivated
        serves `all`. Always render `meta.period` / `meta.category` rather than
        echoing what was sent, or the filter chrome will claim a filter that was
        never applied.
      security:
      - BearerAuth: []
      parameters:
      - name: period
        in: query
        required: false
        schema:
          type: string
          enum:
          - week
          - month
          - quarter
          - year
          default: month
        description: Rolling window ending now — `month` is the last ~30 days, not
          the calendar month. An unrecognized value serves `month` rather than erroring;
          read `meta.period` for what was applied and `meta.period_start` / `meta.period_end`
          for the exact window.
      - name: category
        in: query
        required: false
        schema:
          type: string
          default: all
        description: A RecognitionCategory slug from `meta.available_categories`,
          or `all`. A slug is matched across EVERY active category carrying it — the
          same slug can exist under more than one program — so picking "Teamwork"
          counts all of them. An unknown, deleted or deactivated slug serves `all`;
          read `meta.category` and `meta.counting_basis`.
      responses:
        '200':
          description: Leaderboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - top_recipients
                - top_givers
                - my_standing
                - meta
                properties:
                  top_recipients:
                    type: array
                    description: Who was recognized most in the window, highest first.
                      At most `meta.limit` rows. Empty when nobody was recognized
                      — render the empty state, not an error.
                    items:
                      "$ref": "#/components/schemas/RecognitionLeaderboardRow"
                  top_givers:
                    type: array
                    description: Who recognized others most in the window, highest
                      first. Anonymous gives are excluded, so someone who only gave
                      anonymously does not appear.
                    items:
                      "$ref": "#/components/schemas/RecognitionLeaderboardRow"
                  my_standing:
                    type: object
                    description: The caller's own position on each FULL board (not
                      the visible slice), so a client can pin a "You" footer row when
                      they rank below the top N.
                    required:
                    - recipient
                    - giver
                    properties:
                      recipient:
                        "$ref": "#/components/schemas/RecognitionLeaderboardStanding"
                      giver:
                        "$ref": "#/components/schemas/RecognitionLeaderboardStanding"
                  meta:
                    type: object
                    required:
                    - period
                    - available_periods
                    - period_start
                    - period_end
                    - category
                    - available_categories
                    - counting_basis
                    - basis_note
                    - givers_empty_reason
                    - limit
                    - capabilities
                    properties:
                      period:
                        type: string
                        enum:
                        - week
                        - month
                        - quarter
                        - year
                        description: The period ACTUALLY applied, after coercion.
                        example: month
                      available_periods:
                        type: array
                        items:
                          type: string
                        example:
                        - week
                        - month
                        - quarter
                        - year
                      period_start:
                        type: string
                        format: date-time
                        description: Inclusive start of the rolling window.
                        example: '2026-07-13T17:49:10.082Z'
                      period_end:
                        type: string
                        format: date-time
                        description: Inclusive end of the window — the time the request
                          was served.
                        example: '2026-08-13T17:49:10.084Z'
                      category:
                        type: string
                        description: The category slug ACTUALLY applied, or `all`.
                          An unknown or deactivated slug reports back as `all`.
                        example: all
                      available_categories:
                        type: array
                        description: The filter options, `all` first, then the active
                          categories of active programs. Deduplicated by slug — two
                          programs can each define "Teamwork" and the filter covers
                          both.
                        items:
                          type: object
                          properties:
                            slug:
                              type: string
                              example: teamwork
                            name:
                              type: string
                              example: Teamwork
                      counting_basis:
                        type: string
                        enum:
                        - awards_and_shout_outs
                        - awards_only
                        description: Which tables produced the numbers. `awards_only`
                          whenever a category is applied, because peer shout-outs
                          carry no category.
                        example: awards_and_shout_outs
                      basis_note:
                        type: string
                        nullable: true
                        description: The sentence to show the viewer alongside the
                          filter when the basis narrowed. Null on the All Categories
                          view. Render it — otherwise a giver's rank collapsing under
                          a category filter reads as a bug.
                        example: Counting formal awards only — peer shout-outs aren't
                          category-tagged, so they're excluded from this view.
                      givers_empty_reason:
                        type: string
                        nullable: true
                        description: Non-null only when `top_givers` is EMPTY because
                          every give in this view was excluded (awarded by automation,
                          or anonymous) while people were still recognized — a category
                          such as Work Anniversary is awarded entirely by automation,
                          so it has no human givers at all. Null whenever the board
                          has rows, and null when nothing was recognized (a plain
                          "no activity yet" state is honest there). Render this instead
                          of a generic "be the first to recognize someone" prompt,
                          which would urge an action nobody can take in that view.
                        example: Recognition here was awarded automatically or given
                          anonymously, so no one is credited on this board.
                      limit:
                        type: integer
                        description: Maximum rows per board.
                        example: 5
                      capabilities:
                        type: object
                        properties:
                          can_give_recognition:
                            type: boolean
                            description: Whether THIS caller may give peer recognition
                              — the same guard the submit path enforces, so a client
                              never offers a Give button whose POST would be rejected.
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/leaderboard/categories":
    get:
      tags:
      - Recognitions
      summary: Leaderboard category filter options
      description: |
        The **Filter by category** options for the Leaderboard, on their own so a
        client can build the picker without pulling a board first (the mobile
        filter sheet opens before any board is chosen).

        This is the SAME list the board's own `meta.available_categories` is built
        from — active categories of active programs, `all` first, **deduplicated
        by slug** (a slug is unique per program, not per business, so two programs
        can each define "Teamwork" and the filter covers both with one option).
        Because both surfaces read the one query object, the picker can never
        offer a category the board would then reject.

        Each real option is enriched beyond the board's slug+name pair with the
        `color`, `icon` and `description` a colored filter chip needs, so the
        picker renders without a second round-trip. Those display fields fall back
        to a neutral swatch (`#95a5a6`) and a `star` icon when the tenant left
        them blank, so a client never has to invent a default.

        The list is **business-wide and identical for every role**, exactly like
        the board it filters — the endpoint takes no parameters. Selecting any
        category narrows the board to formal awards only (peer shout-outs carry no
        category); `meta.counting_basis_note` is the sentence to surface next to
        the picker so that narrowing never reads as a bug.

        One query serves the whole list; every field is a column already on the
        loaded row, so there is no per-category lookup regardless of how many
        categories the tenant has.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Filter options retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - categories
                - meta
                properties:
                  categories:
                    type: array
                    description: The picker options, `all` first, then one option
                      per active category of an active program, ordered by the category's
                      sort order then name, deduplicated by slug.
                    items:
                      type: object
                      required:
                      - slug
                      - name
                      - is_all
                      properties:
                        slug:
                          type: string
                          description: The RecognitionCategory slug to pass as the
                            board's `?category=` param, or `all` for the sentinel.
                          example: teamwork
                        name:
                          type: string
                          example: Teamwork
                        is_all:
                          type: boolean
                          description: True only for the "All Categories" sentinel,
                            so a client selects "no filter" without string-matching
                            the slug.
                          example: false
                        color:
                          type: string
                          nullable: true
                          description: Hex swatch for a filter chip. Null on the `all`
                            sentinel; a neutral `#95a5a6` when the tenant left the
                            category's color blank.
                          example: "#3498db"
                        icon:
                          type: string
                          nullable: true
                          description: Icon name for the chip. Null on the `all` sentinel;
                            a `star` fallback when the tenant left it blank.
                          example: users
                        description:
                          type: string
                          nullable: true
                          description: The category's description, or null when blank.
                          example: Working well together
                  meta:
                    type: object
                    required:
                    - count
                    - counting_basis_note
                    properties:
                      count:
                        type: integer
                        description: The number of REAL categories offered — the `all`
                          sentinel is NOT counted, so this can be shown as "5 categories"
                          honestly.
                        example: 4
                      counting_basis_note:
                        type: string
                        description: 'The sentence to show next to the picker: selecting
                          any category counts formal awards only, because peer shout-outs
                          carry no category. Mirrors the board''s `meta.basis_note`,
                          shown BEFORE a category is applied rather than after.'
                        example: Selecting a category counts formal awards only —
                          peer shout-outs aren't category-tagged, so they're excluded
                          from a category view.
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/programs":
    get:
      tags:
      - Recognitions
      summary: Recognition programs
      description: |
        The native-client mirror of the web Programs page. Every program, count,
        points figure and CTA is produced by the same query object that backs the
        web page (`Recognition::ProgramsQuery`), so the two surfaces cannot drift.

        **Two lists, because they answer different questions:**

        * `programs` — the ones a nomination can actually be filed under.
          Paginated (12 per page, matching the web grid). This is the same basis
          as the dashboard's `active_programs` card, so that card's own "View All"
          can never land on a longer list than it counted.
        * `automatic_programs` — birthdays, work anniversaries and tenure
          milestones, awarded by the lifecycle job. Nobody nominates in these, so
          they are a separate section rather than padding the first one. Served
          whole (a tenant has a handful at most) and every row reports
          `can_nominate: false` with the automatic explanation.

        Only **active** programs **inside their start/end window** are listed. A
        draft, retired, not-yet-open or closed program is absent from both lists.

        **The role story is `can_nominate`, and nothing else.** There is no
        role-scoped hiding: an employee, a manager and an admin all receive the
        same programs. What differs is whether each card offers the CTA, and that
        answer comes from the program's own eligibility rule — the SAME predicate
        the nomination submit path enforces, so a client is never offered a button
        whose POST would be rejected:

        * a `peer_to_peer` program is open to everyone;
        * a `manager_to_employee` program is open to **managers only**, both ways
          — an admin who is not a manager is blocked too;
        * an `achievement_based` program with nominator `eligibility_criteria`
          (tenure, department, location, role) answers per profile;
        * a `milestone` program is never nominatable by anyone.

        When `can_nominate` is false, `nomination_block_reason` says why in the
        viewer's own words. **Render it** — a disabled button with no reason and
        no next step is a dead end, and the web card shows the same sentence.

        `reward_points` presents the program's monthly recognition budget in the
        POINTS employees see everywhere else in this app (the economy is stored in
        dollars; 1 point = 1¢, at the tenant's `points_per_dollar` store setting).
        The dollar figures ride along for consumers that report currency.

        Requires ad-hoc award requests ("Model A") to be enabled for the tenant —
        the same gate that hides the web Programs tab. A tenant with it off gets
        403 `feature_disabled` rather than a list of programs nobody there can
        nominate in.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based page number. Paginates `programs` only — `automatic_programs`
          is served whole on every page.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 12
        description: Cards per page, clamped to 50. Defaults to 12, matching the web
          grid, so the two surfaces page identically.
      responses:
        '200':
          description: Programs retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - programs
                - automatic_programs
                - meta
                properties:
                  programs:
                    type: array
                    description: Programs this viewer can browse and (subject to `can_nominate`)
                      nominate in. Ordered by name. Paginated.
                    items:
                      "$ref": "#/components/schemas/RecognitionProgramCard"
                  automatic_programs:
                    type: array
                    description: 'System-awarded milestone programs — every row has
                      `is_automatic: true`, `can_nominate: false` and `my_nominations:
                      0`. Not paginated.'
                    items:
                      "$ref": "#/components/schemas/RecognitionProgramCard"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 12
                      total_count:
                        type: integer
                        description: Nominatable programs across all pages. Describes
                          `programs` only; `automatic_programs` is served whole, so
                          its length IS its total.
                        example: 3
                      total_pages:
                        type: integer
                        example: 1
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                      automatic_programs_count:
                        type: integer
                        description: Length of `automatic_programs`.
                        example: 2
                      capabilities:
                        type: object
                        properties:
                          can_nominate_any:
                            type: boolean
                            description: Whether this viewer can nominate in ANY program
                              on this page — the web header's "Nominate Someone" button.
                              False means the nominee picker would be empty for them,
                              so hide the entry point.
                            example: true
                          can_give_recognition:
                            type: boolean
                            description: Whether this viewer may give peer recognition
                              at all — the same guard the submit path enforces.
                            example: true
                          award_requests_enabled:
                            type: boolean
                            description: Model A. Always true in a 200 response —
                              the endpoint 403s when it is off — and reported so a
                              client can cache one settings shape.
                            example: true
                          award_cycles_enabled:
                            type: boolean
                            description: Model B (time-boxed award cycles). Independent
                              of this endpoint; reported so a client knows whether
                              the Award Cycles surface exists for this tenant.
                            example: false
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or ad-hoc award requests are disabled for the tenant, which also hides
            the web Programs tab (error code `feature_disabled`).
  "/recognitions/team":
    get:
      tags:
      - Recognitions
      summary: Team Recognition
      description: |
        The native-client mirror of the web **Team Recognition** page. Every
        number, list and scope is produced by the same query object that backs
        the web page (`Recognition::TeamStats`), so the two surfaces cannot drift
        on what a number means, who is on "my team", or who may see a row.

        **Reviewer-only.** The web page is gated on the canonical
        `User#recognition_reviewer?` — a business admin, a Recognitions app
        admin, or anyone with direct reports — and redirects everyone else. This
        endpoint refuses with **403 `forbidden`** rather than returning an
        all-zero payload, which a client would render as "your team has never
        been recognized". Check `viewer.is_reviewer` on the dashboard endpoint
        before offering the Team tab.

        **Manager vs admin.** `viewer.is_recognition_admin` changes the reach of
        two sections, and the client should label them accordingly:

        * `pending_approvals` — an admin's queue spans the whole tenant; a
          manager's is limited to nominations they may actually review (assigned
          to them, unassigned, or about one of their own reports) plus the posts
          routed to them.
        * `upcoming_anniversaries` — an admin sees the whole active workforce; a
          manager sees only their own team.

        `team_members` is always the viewer's OWN reports, for both personas —
        which is why an admin with nobody reporting to them gets an empty roster
        beside a full approvals queue. The web page says exactly that in its
        empty state.

        **Counting rules**, applied identically to every tile and list:

        * Recognition lives on two tables — formal **awards** and peer
          **shout-outs** — and every received/given number rolls up both. Every
          shout-out counts, not only point-bearing ones: a tenant that doesn't
          use redeemable rewards collects no points on the give form at all.
        * Revoked awards are excluded everywhere (their store points have been
          clawed back).
        * Private posts are excluded everywhere **except** `needs_recognition`.
          That panel prints no content, and someone recognized privately HAS been
          recognized — flagging them would nag the manager into a duplicate.

        All values that represent money are reported in **reward points**;
        storage is in dollars and the conversion happens server-side, so a client
        never needs the tenant's points-per-dollar rate.
      parameters:
      - name: team_scope
        in: query
        required: false
        schema:
          type: string
          enum:
          - direct
          - all
          default: direct
        description: "`direct` (default) lists the viewer's direct reports; `all`
          widens to their whole org subtree. A **request, not the answer** — when
          no direct report has reports of their own, `all` is the same set, so the
          server forces the response back to `direct` and reports `scope.has_indirect_reports:
          false`. Read `scope.current`, never the value you sent, and only offer the
          toggle when `scope.has_indirect_reports` is true."
      responses:
        '200':
          description: The Team screen
          content:
            application/json:
              schema:
                type: object
                properties:
                  team:
                    type: object
                    properties:
                      viewer:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          is_reviewer:
                            type: boolean
                            description: Always true in a 200 — the gate refuses everyone
                              else. Sent so the payload is self-describing.
                            example: true
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin. Widens `pending_approvals` to the tenant
                              and `upcoming_anniversaries` to the whole workforce.
                            example: false
                          can_give_recognition:
                            type: boolean
                            description: Whether the tenant lets THIS user give peer
                              recognition — the same guard the submit path enforces,
                              so a Give button whose POST would be rejected is never
                              offered.
                            example: true
                      features:
                        type: object
                        description: Tenant toggles, mirrored (not re-derived) from
                          the same settings that gate the web nav and views, so a
                          client can hide exactly the surfaces the web page hides.
                        properties:
                          award_requests_enabled:
                            type: boolean
                            description: Model A — ad-hoc award requests. Defaults
                              on.
                            example: true
                          award_cycles_enabled:
                            type: boolean
                            description: Model B — time-boxed award cycles. Defaults
                              off.
                            example: false
                          quick_award_enabled:
                            type: boolean
                            description: Instant manager award, no approval step.
                              Gates the Recognize / Celebrate button the web page
                              hangs off every roster, gap and anniversary row.
                            example: true
                          peer_points_enabled:
                            type: boolean
                            description: Whether a peer allowance pool exists at all.
                              When false, `my_giving` carries program budgets only.
                            example: false
                      scope:
                        type: object
                        properties:
                          current:
                            type: string
                            enum:
                            - direct
                            - all
                            description: The scope actually in force — read this,
                              not the request.
                            example: direct
                          has_indirect_reports:
                            type: boolean
                            description: Whether any direct report has reports of
                              their own. False → the `all` toggle is meaningless;
                              don't render it.
                            example: false
                          label:
                            type: string
                            description: Ready-to-render label matching `current`.
                            example: Direct reports
                      stats:
                        type: object
                        description: The four tiles the screen leads with. Received
                          and Given carry the awards-vs-shout-outs split alongside
                          the total (the total IS the split), so a client can render
                          the sub-line without a second request.
                        properties:
                          recognition_received:
                            "$ref": "#/components/schemas/RecognitionTeamCountTile"
                          recognition_given:
                            "$ref": "#/components/schemas/RecognitionTeamCountTile"
                          total_value:
                            type: object
                            properties:
                              points:
                                type: integer
                                description: The team's total recognition value in
                                  reward points, converted from the dollars awards
                                  are stored in.
                                example: 4250
                          team_members:
                            type: object
                            properties:
                              total:
                                type: integer
                                description: Always equals the length of `team_members`
                                  — the tile and the roster count the same set.
                                example: 6
                              scope:
                                type: string
                                enum:
                                - direct
                                - all
                                description: Which scope produced this count.
                                example: direct
                      my_giving:
                        type: array
                        description: One row per pool the viewer can give from — the
                          monthly peer allowance and each award-program budget they
                          draw on. Empty when the tenant has configured neither.
                        items:
                          "$ref": "#/components/schemas/RecognitionTeamGivingPool"
                      pending_approvals:
                        type: object
                        description: The viewer's approval queue. Recognition stays
                          hidden from the recipient until a reviewer decides, so a
                          reviewer who never learns they have a queue silently blocks
                          it.
                        properties:
                          total:
                            type: integer
                            description: '`nominations_count + posts_count` — the
                              badge number. Counting nominations alone left reviewers
                              reading "Pending (0)" while posts sat unapproved.'
                            example: 3
                          nominations_count:
                            type: integer
                            description: The FULL queue size, computed before the
                              preview limit — never capped at the number of rows below.
                            example: 2
                          posts_count:
                            type: integer
                            example: 1
                          nominations:
                            type: array
                            description: 'The first 5 nominations. Pending POSTS are
                              deliberately not previewed: an unapproved post is unpublished
                              recognition, so its body is shown nowhere until it is
                              approved — send the reviewer to the approvals screen
                              for those.'
                            items:
                              "$ref": "#/components/schemas/RecognitionTeamPendingNomination"
                      needs_recognition:
                        type: object
                        description: The fairness signal — who is going unrecognized.
                        properties:
                          count:
                            type: integer
                            description: The FULL number flagged. `members` is capped
                              at 8, so render "+N more" from the difference rather
                              than implying the list is complete.
                            example: 3
                          threshold_days:
                            type: integer
                            description: A member is flagged after this long with
                              nothing at all.
                            example: 30
                          members:
                            type: array
                            description: Never-recognized first, then longest gap
                              first. At most 8 rows.
                            items:
                              "$ref": "#/components/schemas/RecognitionTeamGapRow"
                      upcoming_anniversaries:
                        type: object
                        properties:
                          window_days:
                            type: integer
                            description: How far ahead the list looks.
                            example: 30
                          entries:
                            type: array
                            description: Soonest first, at most 5.
                            items:
                              "$ref": "#/components/schemas/RecognitionTeamAnniversary"
                      team_highlights:
                        type: object
                        description: Who stands out this period. Both rows are **null**
                          when the team has no recognition at all — render nothing
                          rather than a zero-count row (the web prints an em dash).
                        properties:
                          most_recognized:
                            "$ref": "#/components/schemas/RecognitionTeamHighlight"
                          most_recognition_given:
                            "$ref": "#/components/schemas/RecognitionTeamHighlight"
                      recent_team_recognition:
                        type: array
                        description: The merged award + shout-out activity stream
                          for this team, newest first, at most 5 rows.
                        items:
                          "$ref": "#/components/schemas/RecognitionTeamActivityRow"
                      team_members:
                        type: array
                        description: The roster the web renders as a table, each row
                          carrying that person's received / given / this-month counts.
                          The counts come from the same grouped queries the tiles
                          use, so the table costs no extra query. Always the viewer's
                          OWN reports — empty for an admin with nobody reporting to
                          them.
                        items:
                          "$ref": "#/components/schemas/RecognitionTeamMemberRow"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the caller is not a reviewer — no direct reports and not a Recognitions
            admin (error code `forbidden`), which is the API's answer to the web page's
            redirect.
  "/recognitions/team/members/{id}":
    get:
      tags:
      - Recognitions
      summary: One team member's recognition profile
      description: |
        The native-client mirror of the web **team member** screen — the page a
        row of the Team roster opens (`/recognition/manager/team_member/{id}`).
        Everything a manager can see about one person's recognition: five tiles
        and three lists.

        Every number and list is produced by the same query object that backs the
        web page (`Recognition::TeamMemberProfile`), so the two surfaces cannot
        drift on who may open a profile, what a tile counts, or which rows are
        safe to show the subject's manager.

        **Two gates, exactly like the web page.**

        1. The SURFACE is reviewer-only — a business admin, a Recognitions app
           admin, or anyone with direct reports (`User#recognition_reviewer?`).
           A plain employee gets **403 `forbidden`**. Check
           `viewer.is_reviewer` on `/recognitions/dashboard` before offering the
           Team tab at all.
        2. The MEMBER is then checked individually. A recognition admin reaches
           anyone in the tenant; a manager reaches only their own direct reports
           or someone deeper in their org subtree. Anyone else is **403
           `forbidden`** with the page's own wording.

        A user outside this tenant, or a nonexistent id, is **404** — indistinct
        from each other, so this endpoint can't be used to probe another tenant's
        user ids. Note that **nobody is on their own team**: a manager asking for
        their own id gets 403. `/recognitions/my_recognition` is that screen.

        **Counting rules**, applied identically to every tile and every list:

        * Recognition lives on two tables — formal **awards** and peer
          **shout-outs**. The two "This Month" tiles roll up BOTH, because the
          "This Month" column on the roster that links here does; the awards /
          shout-out split is published alongside each total so a client can
          render the sub-line without a second request.
        * **Revoked awards are excluded everywhere** — their store points have
          been clawed back, so they are gone from the leaderboard and the
          member's own record too.
        * **Private posts are excluded everywhere**, from the counts as well as
          the lists. The give form promises a private post is "Only you and the
          recipient", and this screen renders post bodies to the recipient's
          MANAGER — so counting one the list can never show would disclose that
          it exists.
        * Posts that are not active (pending or rejected moderation) are
          excluded: they are unpublished recognition.

        **Not paginated.** Each list returns the latest 20 rows, newest first,
        alongside the full `total_count` and a `has_more` flag — which is what
        the web prints as "Showing latest 20 of N". For deeper history use the
        browsable, paginated `/recognitions/feed`.

        All values that represent money are reported in **reward points**;
        storage is in dollars and the conversion happens server-side, so a client
        never needs the tenant's points-per-dollar rate.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The team member's user id — the `user.id` of a `team_members`
          row from `/recognitions/team`.
      responses:
        '200':
          description: The team member's recognition profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  team_member:
                    type: object
                    properties:
                      member:
                        "$ref": "#/components/schemas/RecognitionProfileSubject"
                      viewer:
                        type: object
                        description: The header's affordances, as the server will
                          actually answer them.
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin. An admin viewing somebody else's report is
                              not looking at "my team" — label the two differently.
                            example: false
                          can_revoke_awards:
                            type: boolean
                            description: 'Whether to offer **Revoke** on award rows.
                              Clawback is admin-only even though managers may open
                              this screen, so this is false for a manager. Uniform
                              across both award lists rather than per row: every row
                              here is an active award, and the control does not vary
                              by which one it points at.'
                            example: false
                          can_give_recognition:
                            type: boolean
                            description: The "Recognize" button. The same guard the
                              give endpoint enforces, so a button whose POST would
                              be rejected is never offered.
                            example: true
                      features:
                        type: object
                        properties:
                          quick_award_enabled:
                            type: boolean
                            description: The "Quick Award" button — instant manager
                              award, no approval step. The tenant switch alone; this
                              endpoint is already reviewer-gated, which is the other
                              half of the write path's guard.
                            example: true
                      stats:
                        type: object
                        description: The five tiles.
                        properties:
                          awards_received:
                            type: integer
                            description: All-time active awards received.
                            example: 5
                          awards_given:
                            type: integer
                            description: All-time active awards this member gave.
                            example: 3
                          posts_received:
                            type: integer
                            description: All-time active, non-private shout-outs received
                              — the same set the `recognition_posts_received` list
                              draws from.
                            example: 15
                          this_month_received:
                            "$ref": "#/components/schemas/RecognitionProfileMonthTile"
                          this_month_given:
                            "$ref": "#/components/schemas/RecognitionProfileMonthTile"
                      awards_received:
                        allOf:
                        - "$ref": "#/components/schemas/RecognitionProfileSection"
                        description: Formal awards this member received, newest first.
                      recognition_posts_received:
                        allOf:
                        - "$ref": "#/components/schemas/RecognitionProfileSection"
                        description: Peer shout-outs this member received, newest
                          first. Private ones are never included.
                      awards_given:
                        allOf:
                        - "$ref": "#/components/schemas/RecognitionProfileSection"
                        description: Awards this member gave, newest first — each
                          row names the RECIPIENT.
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'One of three refusals, distinguished by `error.code`: the
            Recognitions app is not enabled for the tenant or this user is outside
            the app''s audience (`access_denied`); the caller is not a reviewer —
            no direct reports and not a Recognitions admin (`forbidden`); or the caller
            is a reviewer but this member is not on their team (`forbidden`, message
            "You can only view recognition for people on your team"), which is the
            API''s answer to the web page''s redirect.'
        '404':
          description: No such member in this tenant. Deliberately indistinguishable
            from a nonexistent id, so the endpoint can't be used to enumerate another
            tenant's users.
  "/recognitions/anniversary_roster":
    get:
      tags:
      - Recognitions
      summary: Anniversary Roster — the full work-anniversary list, filtered
      description: |
        The native-client mirror of the web **Anniversary Roster**, which is where
        the Team screen's "Upcoming anniversaries" card sends **View all**. Both
        surfaces read the same query object
        (`Recognition::AnniversaryRosterQuery`), so the filters, the reach and
        every tile are the same code on both.

        **This is the roster; `/recognitions/team` carries a teaser.** That
        endpoint's `upcoming_anniversaries` is a fixed 30-day window, five rows,
        no filters. This one takes the viewer's own date range and milestone-year
        filter, reports the four summary tiles, and pages.

        **Reviewer-only.** Gated on the canonical `User#recognition_reviewer?` —
        a business admin, a Recognitions app admin, or anyone with direct reports
        — exactly like the web page, which redirects everyone else. This endpoint
        answers **403 `forbidden`** rather than an empty roster, which a client
        would render as "nobody has an anniversary".

        **Reach follows the persona**, and `scope.reach` says which you got:

        * `workforce` — a recognition admin sees every active employee, because
          they own the milestone program.
        * `team` — anyone else sees their DIRECT reports. There is no
          "all reports" toggle here, because the web page has none.

        Only people with a hire date are listed, and only from their FIRST
        anniversary onward — a hire from last month has nothing to celebrate yet.
        A Feb 29 hire is observed on Feb 28 in non-leap years.

        **The tiles describe the FULL filtered roster, never the page** — a count
        that only covered page 1 would contradict the list beside it. `upcoming`
        therefore always equals `meta.total_count`.

        **Filters are refused, not coerced** — unlike `/recognitions/leaderboard`,
        where an unknown period sensibly falls back to `month`. There is no
        sensible fallback for a date: quietly serving the default window for
        `from_date=last-tuesday`, or an empty list for a backwards range, both
        read to the user as "nobody has an anniversary" when the truth is "your
        filter didn't arrive". Each of these is **400 `invalid_parameter`**:

        * a date that can't be parsed,
        * `to_date` before `from_date`,
        * a range wider than 365 days (past a year every calendar day is in the
          window, so the answer can't differ and the scan can't be narrowed),
        * a `milestone_years` with no readable year in it.

        Render `filters` rather than echoing what you sent: blank params resolve
        to the default window server-side.
      parameters:
      - name: from_date
        in: query
        required: false
        schema:
          type: string
          format: date
        description: Start of the window (inclusive). Defaults to **today**. `start_date`
          is accepted as an alias, so a roster URL copied out of the web app works
          unchanged.
      - name: to_date
        in: query
        required: false
        schema:
          type: string
          format: date
        description: End of the window (inclusive). Defaults to **today + 90 days**,
          the web page's own window. `end_date` is accepted as an alias. Must be on
          or after `from_date`, and no more than 365 days after it.
      - name: milestone_years
        in: query
        required: false
        schema:
          type: string
          example: '5,10,15'
        description: Comma-separated service-year counts — only people completing
          exactly these years are listed. Omit for every year. Whitespace and duplicates
          are tolerated; a value with no readable year in it is a 400 rather than
          a silent unfiltered list. `filters.milestone_year_options` carries the presets
          the web filter offers.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
        description: Defaults to 50, the web table's page size. Clamped to 100.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The Anniversary Roster screen
          content:
            application/json:
              schema:
                type: object
                properties:
                  anniversary_roster:
                    type: object
                    properties:
                      viewer:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          is_reviewer:
                            type: boolean
                            description: Always true in a 200 — the gate refuses everyone
                              else. Sent so the payload is self-describing.
                            example: true
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin. Widens the roster from this manager's reports
                              to the whole active workforce.
                            example: false
                          can_give_recognition:
                            type: boolean
                            example: true
                      features:
                        type: object
                        properties:
                          quick_award_enabled:
                            type: boolean
                            description: 'Instant manager award, no approval step.
                              Gates the Recognize / Celebrate button on every row
                              — when false, no row reports `can_recognize: true`.'
                            example: true
                      scope:
                        type: object
                        description: Whose anniversaries this roster covers.
                        properties:
                          reach:
                            type: string
                            enum:
                            - workforce
                            - team
                            description: "`workforce` for a recognition admin, `team`
                              (the viewer's direct reports) for anyone else. Label
                              the list accordingly — the same roster means different
                              things to the two personas."
                            example: team
                          label:
                            type: string
                            example: Your direct reports
                      filters:
                        type: object
                        description: What was actually applied, plus what the picker
                          should offer.
                        properties:
                          from_date:
                            type: string
                            format: date
                          to_date:
                            type: string
                            format: date
                          window_days:
                            type: integer
                            description: Days between from_date and to_date.
                            example: 90
                          max_window_days:
                            type: integer
                            description: The widest range this endpoint serves. Asking
                              for more is a 400.
                            example: 365
                          milestone_years:
                            type: array
                            nullable: true
                            description: The years actually filtered on, parsed and
                              sorted. **Null** means every year, which is the default.
                            items:
                              type: integer
                            example:
                            - 5
                            - 10
                          is_filtered:
                            type: boolean
                            description: Whether anything is narrowed — i.e. whether
                              a "Clear filters" affordance is worth showing. Matches
                              the web empty state's own condition.
                            example: false
                          defaults:
                            type: object
                            description: The window served when no dates are sent.
                            properties:
                              from_date:
                                type: string
                                format: date
                              to_date:
                                type: string
                                format: date
                              window_days:
                                type: integer
                                example: 90
                          milestone_year_options:
                            type: array
                            description: 'The presets the web filter''s select offers,
                              so a native picker renders the same choices instead
                              of maintaining a second list that can drift. `years:
                              null` is "All Years".'
                            items:
                              type: object
                              properties:
                                label:
                                  type: string
                                  example: Mid (5, 10, 15)
                                years:
                                  type: array
                                  nullable: true
                                  items:
                                    type: integer
                                  example:
                                  - 5
                                  - 10
                                  - 15
                      stats:
                        type: object
                        description: The web page's four tiles, in its order. Every
                          one describes the FULL filtered roster, never the page —
                          `upcoming` always equals `meta.total_count`.
                        properties:
                          upcoming:
                            type: integer
                            description: Everyone in the window, after filtering.
                            example: 24
                          this_month:
                            type: integer
                            description: How many fall in the CURRENT calendar month
                              — not the first month of the window, so a range starting
                              next March doesn't report March as "this month".
                            example: 6
                          milestone_years:
                            type: array
                            description: The distinct service-year counts present
                              in the roster, ascending — the LIST, not its size. ("4"
                              beside "Upcoming 120" read as "only 4 of them are milestones".)
                              Ships whole.
                            items:
                              type: integer
                            example:
                            - 1
                            - 3
                            - 5
                            - 10
                          milestone_years_display_limit:
                            type: integer
                            description: Where the web tile collapses the rest into
                              "+N more". Guidance, not a rule.
                            example: 8
                          recognized:
                            type: integer
                            description: How many already received their years-of-service
                              award this year.
                            example: 3
                      entries:
                        type: array
                        description: One page of the roster, soonest anniversary first.
                        items:
                          "$ref": "#/components/schemas/RecognitionAnniversaryRosterRow"
                      meta:
                        type: object
                        description: Page envelope. Counts describe the whole filtered
                          roster.
                        properties:
                          current_page:
                            type: integer
                            example: 1
                          per_page:
                            type: integer
                            example: 50
                          total_count:
                            type: integer
                            example: 24
                          total_pages:
                            type: integer
                            example: 1
                          has_next_page:
                            type: boolean
                            example: false
                          has_prev_page:
                            type: boolean
                            example: false
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '400':
          description: 'A filter that couldn''t be read (error code `invalid_parameter`):
            an unparseable date, `to_date` before `from_date`, a range wider than
            365 days, or a `milestone_years` with no readable year. The message names
            the offending parameter — show it rather than a generic failure, since
            the fix is the user''s to make.'
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the caller is not a reviewer — no direct reports and not a Recognitions
            admin (error code `forbidden`), which is the API's answer to the web page's
            redirect.
  "/recognitions/award_cycles":
    get:
      tags:
      - Recognitions
      summary: Award Cycles — open now, opening soon and recent winners
      description: |
        The native-client mirror of the web **Award Cycles** page
        (`/recognition/award-cycles`) — the time-boxed award cycles (Model B, the
        "Mango Champions" pattern) a viewer can nominate into, plus what opens
        next and who recently won. Both surfaces read the same query object
        (`Recognition::AwardCyclesQuery`), so the section a cycle lands in, the
        committee review rule, the nominate gate and the winner names cannot
        drift between them.

        **Four lists, served whole.** There are no pagination parameters: the page
        is four fixed lists, and "Recent winners" is *capped* rather than paginated
        (`meta.recent_winners_limit`, currently 6 — the same depth the web page has
        always shown). A list's length IS its total.

        * `needs_my_review` — closed committee cycles this caller is asked to
          decide. Rendered ABOVE everything else, as the web page's amber
          "Needs your review" card.
        * `open_now` — cycles accepting nominations right now. The hero list, and
          the only one carrying a CTA.
        * `opening_soon` — scheduled cycles whose window has not started.
        * `recent_winners` — decided cycles, each with its winners (and their
          printable certificates) and the full honor roll.

        **Which list a cycle lands in is NOT its stored status.** It is the
        *effective* status, which reconciles the stored column against the live
        window: a cycle stored `scheduled` whose `opens_at` has passed is served
        under `open_now` with `status: "open"`, and one stored `open` whose
        `closes_at` has passed is served in neither browse list. Read the `status`
        the card reports, never re-derive it from the timestamps. Archived cycles
        are never served.

        **No role branching.** An employee, a manager and a recognition admin
        receive exactly the *same* `open_now`, `opening_soon` and
        `recent_winners`. Verified against the live page in the dev tenant: all
        four personas saw the identical three open, two upcoming and two decided
        cycles. Only two things differ per caller, and both delegate to the
        predicate the submit path enforces:

        * `needs_my_review` — the only per-viewer list. A caller is asked only if
          the cycle is a *closed committee* cycle, they are on its committee, and
          they are **not themselves nominated in it** — being up for an award
          recuses you from deciding it. An admin who is not on the committee is
          deliberately NOT nagged, mirroring the web list; `can_review` reports
          the wider gate (which does admit an admin) separately, so a client can
          still offer them the link.
        * `can_nominate` / `nomination_block_reason` — the window must still be
          live AND the caller must be allowed to give peer recognition at all.
          **Render `nomination_block_reason`** instead of a bare disabled button;
          a blocked card always carries one, and an offered card never does.

        **`nominated_by_me` is state, not a gate.** A cycle *pools* nominations, so
        a caller who has already nominated may nominate again. The flag exists so
        a client can say "you've nominated" rather than re-offering a fresh CTA
        with no memory.

        **A winner may have no certificate.** The decide step logs and continues
        when an Award can't be minted, so `winners[].certificate` is nullable.
        Handle the null rather than assuming a certificate is always there. When
        present it carries the printed certificate's fields in its own order —
        title, `awarded_by`, the italic `citation` quote, the program / points /
        company-value chips, then the footer's `unit` (the issuing organization)
        and date — plus `certificate_url` for the printable page.

        **`meta.empty_state` is present only when `open_now` is empty.** It carries
        the web page's own copy, including the conditional "check the Opening soon
        list below" clause, which appears only when `opening_soon` is non-empty.

        **Gated on Model B.** Award cycles are OFF by default. The web page
        redirects a tenant that hasn't opted in; this endpoint answers 403
        `feature_disabled` rather than serving three empty lists that would read as
        "your company runs no awards". Access is additionally gated the same way as
        every other endpoint in this namespace — the Recognitions app must be
        enabled for the tenant AND the caller must be inside the app's audience,
        otherwise 403 `access_denied`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The Award Cycles screen
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycles
                properties:
                  award_cycles:
                    type: object
                    required:
                    - viewer
                    - features
                    - needs_my_review
                    - open_now
                    - opening_soon
                    - recent_winners
                    - meta
                    properties:
                      viewer:
                        type: object
                        required:
                        - id
                        - name
                        - can_give_recognition
                        - is_recognition_admin
                        - has_cycles_to_review
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          can_give_recognition:
                            type: boolean
                            description: Whether this tenant lets THIS caller give
                              peer recognition. When false, every `can_nominate` below
                              is false too — the same guard the submit path enforces.
                            example: true
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin.
                            example: false
                          has_cycles_to_review:
                            type: boolean
                            description: True when `needs_my_review` is non-empty
                              — the web page's amber review card in one boolean.
                            example: false
                      features:
                        type: object
                        description: The tenant toggles, so a client hides the same
                          surfaces the web nav hides.
                        required:
                        - award_cycles_enabled
                        - award_requests_enabled
                        properties:
                          award_cycles_enabled:
                            type: boolean
                            description: Model B. Always true in a 200 — the endpoint
                              403s otherwise. Present so one payload shape describes
                              both nomination models.
                            example: true
                          award_requests_enabled:
                            type: boolean
                            description: Model A (ad-hoc award requests). Defaults
                              on.
                            example: true
                      needs_my_review:
                        type: array
                        description: Closed committee cycles THIS caller is asked
                          to decide. The only per-viewer list. Empty for anyone who
                          is not on a committee, and for a committee member who is
                          themselves nominated in the cycle.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleReviewCard"
                      open_now:
                        type: array
                        description: Cycles accepting nominations right now, newest
                          deadline first. Empty → render `meta.empty_state`, not an
                          error.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleOpenCard"
                      opening_soon:
                        type: array
                        description: Scheduled cycles whose window has not started.
                          Never offers a CTA — the submit path would refuse.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleUpcomingCard"
                      recent_winners:
                        type: array
                        description: Decided cycles, most recently closed first, capped
                          at `meta.recent_winners_limit`.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleDecidedCard"
                      meta:
                        type: object
                        required:
                        - counts
                        - recent_winners_limit
                        - closing_soon_days
                        - capabilities
                        properties:
                          counts:
                            type: object
                            description: The length of each list. Always agrees with
                              the arrays — every list is served whole.
                            properties:
                              open_now:
                                type: integer
                                example: 3
                              opening_soon:
                                type: integer
                                example: 2
                              recent_winners:
                                type: integer
                                example: 2
                              needs_my_review:
                                type: integer
                                example: 1
                          recent_winners_limit:
                            type: integer
                            description: The cap on `recent_winners`. Say "showing
                              the last N" rather than implying the tenant has only
                              ever run N cycles.
                            example: 6
                          closing_soon_days:
                            type: integer
                            description: The window `closing_soon` is computed against.
                              Advisory — it gates nothing.
                            example: 3
                          empty_state:
                            type: object
                            nullable: true
                            description: Present ONLY when `open_now` is empty. The
                              web page's own copy, so the native screen says what
                              the web screen says.
                            properties:
                              key:
                                type: string
                                example: open_now
                              title:
                                type: string
                                example: No awards open right now
                              message:
                                type: string
                                description: The "check the Opening soon list below"
                                  clause appears only when `opening_soon` is non-empty.
                                example: When an award cycle opens for nominations,
                                  it will show up here — check the "Opening soon"
                                  list below.
                          capabilities:
                            type: object
                            properties:
                              can_give_recognition:
                                type: boolean
                                example: true
                              award_cycles_enabled:
                                type: boolean
                                example: true
                              award_requests_enabled:
                                type: boolean
                                example: true
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the tenant has not turned award cycles on (error code `feature_disabled`),
            which is the API's answer to the web page's redirect.
  "/recognitions/award_cycles/{id}/results":
    get:
      tags:
      - Recognitions
      summary: Award Results — one decided cycle's winners, certificates and honor
        roll
      description: |
        The native-client mirror of the web **Award Results** page
        (`/recognition/award-cycles/{id}/results`) — the screen a "Recent winners"
        row opens: the trophy hero with the cycle name and announce date, one card
        per winner with their printable certificate, and the honor roll thanking
        everyone who was nominated.

        **The body IS a `recent_winners` card.** `award_cycle_results` is the same
        `RecognitionAwardCycleDecidedCard` that
        `GET /recognitions/award_cycles` serves under `recent_winners`, plus a
        `meta` block carrying the results page's copy. Both are produced by one
        serializer, so a client that already holds the row can render this screen
        from the same model and refresh it from here — and the two can never name
        a different winner, a different certificate or a different honor roll.

        **Why the endpoint exists at all.** The list *caps* "Recent winners" at
        `meta.recent_winners_limit` (6) and never pages past it. Results for the
        seventh cycle back — reached from a notification, a shared certificate or
        a search hit — appear in no list response, and the web page has always been
        able to show them. This is that lookup.

        **A winner may have no certificate.** The decide step logs and continues
        when an Award can't be minted, so `winners[].certificate` is nullable.
        Handle the null rather than assuming a certificate is always there.

        **`winners` may be empty while `honor_roll` is not.** Nobody clearing a
        Spotlight cycle's threshold is a real outcome, not an error — people were
        nominated, none met the bar. `meta.empty_state` carries the web page's own
        line for it, and the honor roll still stands.

        **Nothing viewer-wide is served here.** There is no `viewer` block and no
        `needs_my_review`: this endpoint's query object is narrowed to ONE cycle,
        so any capability computed from it would report "nothing to review" while
        other cycles wait on the caller. Read those from
        `GET /recognitions/award_cycles`.

        **No role branching.** Results are a company-wide celebration — the web
        action has no role check — so an employee, a manager and a recognition
        admin receive byte-identical payloads.

        **Gated on Model B**, exactly like the list: award cycles are OFF by
        default, and a tenant that hasn't opted in gets 403 `feature_disabled`
        rather than a deep link that works behind a hidden tab. Access is
        additionally gated the same way as every other endpoint in this namespace
        — the Recognitions app must be enabled for the tenant AND the caller must
        be inside the app's audience, otherwise 403 `access_denied`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle's id — the `id` on any `recent_winners` card.
          Scoped to the caller's tenant; another tenant's cycle 404s, indistinct from
          a missing one.
        example: 11
      responses:
        '200':
          description: The Award Results screen for one decided cycle
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_results
                properties:
                  award_cycle_results:
                    allOf:
                    - "$ref": "#/components/schemas/RecognitionAwardCycleDecidedCard"
                    - type: object
                      required:
                      - meta
                      properties:
                        meta:
                          "$ref": "#/components/schemas/RecognitionAwardCycleResultsMeta"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the tenant has not turned award cycles on (error code `feature_disabled`).
        '404':
          description: No such cycle in this tenant. Another tenant's cycle answers
            the same way as a missing one — the endpoint is enumeration-safe.
        '409':
          description: |
            The cycle exists but has **not been decided yet** (error code
            `results_unavailable`) — the API's answer to the web page's "Results
            aren't available yet." redirect. Deliberately not a 404: the cycle is
            real and will have results, so say "not announced yet", never "gone".

            Covers every non-decided state, including **archived** — `status` is
            one column, so archiving replaces "decided" and the web page redirects
            an archived cycle too.

            `error.details` carries `cycle_id` and the cycle's effective `status`
            (`scheduled` / `open` / `closed` / `archived`), so a client can say
            *why* without a second request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: results_unavailable
                      message:
                        type: string
                        example: Results aren't available yet.
                      details:
                        type: object
                        properties:
                          cycle_id:
                            type: integer
                            example: 10
                          status:
                            type: string
                            enum:
                            - scheduled
                            - open
                            - closed
                            - archived
                            example: closed
  "/recognitions/award_cycles/{id}/review":
    get:
      tags:
      - Recognitions
      summary: Committee review — the anonymized nominee pool for one closed cycle
      description: |
        The native-client mirror of the web **Committee review** page
        (`/recognition/award-cycles/{id}/review`) — the screen a "Needs your
        review" card opens: the anonymized pool of everyone nominated in a closed
        committee cycle, what colleagues wrote about them, who else is on the
        committee, and this member's own saved ballot.

        Both surfaces run the same query object (`Recognition::CycleReviewQuery`),
        so the gate, the pool, its order and the justifications shown cannot drift
        between them. The **write** — `POST /recognitions/award_cycles/{id}/picks`
        — runs the same gate, so a POST can never be accepted on a cycle whose GET
        would have refused.

        **The body opens with a `needs_my_review` card.** Every field of
        `RecognitionAwardCycleReviewCard` is here, produced by the same serializer
        `GET /recognitions/award_cycles` uses, so a client that arrived from that
        list renders this screen from one model and the two can never disagree on
        the deadline, the prize or the pick count.

        **Anonymity is the product, not a formatting choice.** `justifications`
        carry text and an opaque id — no nominator, and nothing that can be joined
        back to one. The running committee signal (how many *other* members picked
        each nominee) is absent for the same reason the web page omits it: a member
        who can see the tally is being anchored rather than asked.

        **The pool is served whole and is NOT paginated** — deliberately, and for
        the same reason the web page isn't: a submit replaces the member's entire
        ballot, so a member deciding from a second page would wipe the first page's
        picks. `pool_count` is a total, not a page size.

        **Ordering is deterministic**: tally descending, then name, then id. Two
        identical requests deal the same rows, quoting the same colleagues in the
        same order.

        **`justifications` is capped** at `meta.justification_preview_limit` (4).
        `additional_justification_count` is the remainder — render "…and N more"
        rather than implying you have them all.

        **The caller's own row can never be picked.** It is flagged `is_me` with
        `can_pick: false`, and the submit path strips the id even if it is sent.
        This holds for an admin who was let past the recusal gate too.

        **An empty pool is a 200, not an error** — a committee cycle nobody
        nominated into is a real state, and `meta.empty_state` carries the web
        page's own line for it.

        **Gated on Model B**, exactly like the list: award cycles are OFF by
        default, and a tenant that hasn't opted in gets 403 `feature_disabled`.
        Access is additionally gated the same way as every other endpoint in this
        namespace — the Recognitions app must be enabled for the tenant AND the
        caller must be inside the app's audience, otherwise 403 `access_denied`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle's id — the `id` on any `needs_my_review` card.
          Scoped to the caller's tenant; another tenant's cycle 404s, indistinct from
          a missing one.
        example: 10
      responses:
        '200':
          description: The committee review screen for one closed committee cycle
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_review
                properties:
                  award_cycle_review:
                    "$ref": "#/components/schemas/RecognitionAwardCycleReview"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            One of four refusals, distinguished by `error.code`:

            * `access_denied` — the Recognitions app is not enabled for the
              tenant, or this user is outside the app's audience.
            * `feature_disabled` — the tenant has not turned award cycles on.
            * `not_committee_member` — the caller is neither on this cycle's
              committee nor a business admin.
            * `recused` — the caller is a committee member who is themselves
              nominated in this cycle. Being up for an award recuses you from
              deciding it. (A business admin is admitted anyway, matching the web,
              but still cannot pick themselves.)

            The last two carry `error.details` with `cycle_id` and the cycle's
            effective `status`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
        '404':
          description: No such cycle in this tenant. Another tenant's cycle answers
            the same way as a missing one — the endpoint is enumeration-safe.
        '409':
          description: |
            The cycle exists but there is nothing to review, by `error.code`:

            * `not_committee_cycle` — a **Spotlight** cycle. Nobody reviews it;
              nominees clear a threshold instead.
            * `cycle_not_closed` — nominations are **still open** (the pool would
              be partial), or the admin has **already decided** the cycle (the
              review is inert). Both are the web page's "This cycle isn't open for
              committee review." redirect.

            Deliberately not a 404: the cycle is real and the caller may well be
            entitled — this just isn't the moment. `error.details` carries
            `cycle_id` and the effective `status` so a client can say *why*
            without a second request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
  "/recognitions/award_cycles/{id}/nominations/new":
    get:
      tags:
      - Recognitions
      summary: Nominate composer — the form for putting a colleague forward
      description: |
        The native-client mirror of the web **Nominate** page
        (`/recognition/award-cycles/{id}/nominate`), the screen the Award Cycles
        list's **Nominate** CTA opens.

        `cycle` is the **same `open_now` card** `GET /recognitions/award_cycles`
        serves, so a client that arrived from that list renders this screen from
        one model and the two can never disagree on the deadline, the prize, the
        criteria, or whether this viewer may nominate. It carries `can_nominate`
        and — when that is false — `nomination_block_reason`, the sentence to show
        instead of a dead disabled button.

        **It ships no nominee roster.** The `nominee_id` typeahead is
        `GET /recognitions/employee_suggestions`, named in
        `form.nominee_search_url`, so this screen does not carry a second copy of
        the tenant's directory. It also does not list who else has been nominated:
        the pool is anonymous until the cycle is decided, and a nominator who
        could see it would be anchored — the same reasoning the committee review
        screen records for omitting its running tally.

        **The GET is not gated on the window.** A scheduled or closed cycle still
        renders, with `can_nominate: false` and the reason — matching the web page,
        and giving a client something to draw. Only the POST refuses (409).

        **Givers only.** Nominating is a *give*, so both verbs are gated on
        `Recognition::GivingAccess.can_give?` — the tenant's peer-recognition
        switch, plus managers and admins always. That is the same predicate behind
        `can_nominate` on every cycle card and behind the web page's own
        `enforce_peer_recognition!` bounce, so a client is never offered a form
        whose POST would be refused.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle being nominated into. Scoped to the caller's
          tenant.
        example: 12
      responses:
        '200':
          description: The cycle, the form and the page's copy
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_nomination_form
                properties:
                  award_cycle_nomination_form:
                    "$ref": "#/components/schemas/RecognitionCycleNominationForm"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied` (Recognitions app off, or the caller is outside
            its audience), `feature_disabled` (the tenant hasn't turned award cycles
            on) or `giving_not_allowed` (this caller may not give recognition, so
            may not nominate). The giving gate runs BEFORE the cycle is looked up,
            so a caller without permission cannot use this endpoint to discover which
            cycle ids exist."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
        '404':
          description: No such cycle in this tenant.
  "/recognitions/award_cycles/{id}/nominations":
    post:
      tags:
      - Recognitions
      summary: Nominate a colleague for an award cycle
      description: |
        The native-client mirror of the web **Submit nomination** button
        (`POST /recognition/award-cycles/{id}/nominate`). Both surfaces run the
        same service (`Recognition::CycleNominationSubmission`), so the giving
        gate, the window check, the nominee rule, the pooled status, the default
        title and the confirmation copy are the same code.

        **This is not `POST /recognitions/nominations`.** That endpoint files a
        Model A ad-hoc award *request*, which routes to a reviewer and can mint an
        Award on the spot. A cycle nomination is **pooled**: it is born `pending`
        and stays there until the cycle closes and is decided — by the Spotlight
        `threshold_nominators` count, or by a committee ballot through
        `POST /recognitions/award_cycles/{id}/picks`. There is no reviewer on this
        path and no per-nomination approve/reject, which is why the response
        carries `pooled: true` and no `requires_approval`.

        **The program comes from the cycle.** `recognition_program_id` is not
        accepted: a cycle nomination belongs to the program running the cycle, and
        sending one is ignored rather than honoured.

        **Nominate again, yes; the same person twice, no.** A cycle pools
        nominations, so one nominator may put several *different* colleagues
        forward — `meta.allows_multiple_nominations` on the composer says so, and
        a client must not hide the CTA after one submit. The model's duplicate
        guard is keyed on (nominator, nominee, cycle), so a second nomination of
        the *same* person is refused with 422 `validation_failed`. That is exactly
        what makes the Spotlight threshold mean "N **distinct** colleagues".

        **Send an `Idempotency-Key`.** The retry is replayed *before* anything is
        written, so a client that times out and retries gets its original 201 back
        rather than tripping the duplicate guard and being told it had already
        nominated someone it does not think it nominated.

        The `nomination` wrapper is optional — the fields may be sent flat, and
        the web form's `nomination[...]` shape is accepted unchanged.

        Gated identically to the composer above: same app access, same Model B
        toggle, same giving permission, same tenant scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle being nominated into. Scoped to the caller's
          tenant.
        example: 12
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry-safety. A repeat with the same key replays the original
          201 and writes nothing — checked before the write, not after.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionCycleNominationRequest"
            examples:
              minimal:
                summary: The two fields the form actually asks for
                value:
                  nomination:
                    nominee_id: 893
                    description: Rebuilt the onboarding checklist and cut new-hire
                      ramp by a week.
              flat:
                summary: The same body without the wrapper
                value:
                  nominee_id: 893
                  description: Rebuilt the onboarding checklist and cut new-hire ramp
                    by a week.
              with_title:
                summary: Overriding the title (defaults to the cycle's name)
                value:
                  nomination:
                    nominee_id: 893
                    title: The onboarding rebuild
                    description: Rebuilt the onboarding checklist and cut new-hire
                      ramp by a week.
      responses:
        '201':
          description: The nomination as filed, plus the cycle card with it counted
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_nomination
                properties:
                  award_cycle_nomination:
                    "$ref": "#/components/schemas/RecognitionCycleNominationResult"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied`, `feature_disabled` or `giving_not_allowed`
            — the same three refusals, with the same wording, as the composer above.
            A rule that guarded only the form is a rule a direct POST walks past."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
        '404':
          description: No such cycle in this tenant.
        '409':
          description: "`cycle_not_open` — the cycle is real and the caller is entitled,
            but the nomination window isn't live: scheduled, closed, decided, archived,
            or force-closed by an admin while `closes_at` is still in the future.
            The state is read through the cycle's EFFECTIVE status, so the stored
            `status` column is not the answer, and `error.details.status` carries
            the reconciled value a client should re-render from."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
        '422':
          description: |
            One of three, and nothing is written in any of them:

            * `invalid_nominee` — no `nominee_id` was sent, or the id is not an
              active member of this tenant.
            * `validation_failed` — the model refused: a self-nomination, a second
              nomination of the same person in this cycle, or a reason outside
              10–1000 characters.
            * `content_rejected` — the tenant's content-moderation keyword screen
              refused the submitted words. This is not cosmetic here: the
              `description` becomes the cycle's org-wide "kind words", which are
              emailed to the winners **and** to every non-winner who was
              nominated, so unscreened text would be mailed to the person it was
              written about. Only what the caller SUBMITTED is screened — a title
              defaulted from the cycle's own name never is.

            `validation_failed` and `content_rejected` both carry
            `error.details.field_errors`, so a native form can mark the offending
            field instead of parsing the sentence.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
  "/recognitions/award_cycles/{id}/picks":
    post:
      tags:
      - Recognitions
      summary: Save a committee ballot — one nominee or several
      description: |
        The native-client mirror of the web review page's **Save my
        recommendations** button (`POST /recognition/award-cycles/{id}/review`).
        Both surfaces run the same service (`Recognition::CyclePickSubmission`),
        so the gate, the pool filter, the never-vote-for-yourself rule and the
        replace semantics are the same code.

        **Single or multiple, one call.** Send `nominee_ids` (an array, or a
        comma-separated string) or `nominee_id` (one scalar) — a member picking one
        person and a member picking six use the same request.

        **It REPLACES the ballot; it does not merge.** A submit is the member's
        complete set of recommendations for this cycle: their existing picks are
        deleted and re-created from what was sent, so sending one id makes that id
        the *only* pick. This is why the review screen is not paginated — send the
        whole selection, never a delta.

        **An explicit empty selection clears the ballot** (`{"nominee_ids": []}`),
        which is how a member withdraws. **Omitting both keys is refused** with 422
        `no_selection` rather than read as "clear everything" — the one place this
        endpoint is stricter than the web form, which always posts its checkbox set.

        **Ids that cannot be picked are dropped, not fatal.** Anyone not in this
        cycle's nominee pool, and the caller's own id, are removed and listed in
        `ignored_ids`. The web renders no checkbox for either; a non-empty
        `ignored_ids` means the client's list is stale.

        **Repeated ids are de-duplicated** — a nominee is picked once or not at all.

        **`changed` reports whether anything actually moved**, so a client can skip
        a needless "saved" toast on a no-op re-submit.

        Nothing here touches another committee member's ballot, and the running
        tally is never returned — read the outcome from
        `GET /recognitions/award_cycles/{id}/review`, and the decided winners from
        `GET /recognitions/award_cycles/{id}/results` once an admin has decided.

        Gated identically to the GET above — same app access, same Model B toggle,
        same four-rule review gate, same status codes.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle being decided. Scoped to the caller's tenant.
        example: 10
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionCyclePicksRequest"
            examples:
              multiple:
                summary: Several nominees (the usual case)
                value:
                  nominee_ids:
                  - 893
                  - 265
              single:
                summary: One nominee, via the scalar shorthand
                value:
                  nominee_id: 893
              comma_separated:
                summary: The web's hidden-field form
                value:
                  nominee_ids: '893,265'
              clear:
                summary: Withdraw — clear this member's ballot
                value:
                  nominee_ids: []
      responses:
        '200':
          description: The ballot after the write
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_picks
                properties:
                  award_cycle_picks:
                    "$ref": "#/components/schemas/RecognitionCyclePicksResult"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied`, `feature_disabled`, `not_committee_member`
            or `recused` — the same four refusals, with the same wording, as the GET
            above. A rule that guarded only the read would be a rule a direct POST
            walked past."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
        '404':
          description: No such cycle in this tenant.
        '409':
          description: "`not_committee_cycle` or `cycle_not_closed` — as the GET above.
            Also returned when the cycle changes state *between* a client's read and
            its write (an admin decided it mid-ballot); nothing is saved."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
        '422':
          description: |
            Neither `nominee_ids` nor `nominee_id` was sent (error code
            `no_selection`). The saved ballot is left untouched. To clear it,
            send an explicit empty array.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: no_selection
                      message:
                        type: string
                        example: Send nominee_ids (or nominee_id). Pass an empty nominee_ids
                          to clear your recommendations.
  "/recognitions/approvals":
    get:
      tags:
      - Recognitions
      summary: Pending Approvals queue — nominations + posts awaiting review
      description: |
        The native-client mirror of the web **Pending Approvals** page, reached
        from Team ▸ Pending Approvals ▸ **View all**. Both surfaces read the same
        query object (`Recognition::ApprovalsQueue`), so the two cannot drift on
        which rows a reviewer sees, in what order, or how the filters behave.

        **Two lists**, exactly as the web page stacks them:

        * `nominations` — the ad-hoc award requests awaiting this reviewer's
          decision (cycle nominations are excluded; those are decided through the
          cycle, never one at a time).
        * `posts` — peer recognition held for this reviewer's **manager
          approval**, still unpublished until a reviewer decides.

        Each list paginates **independently** (`page` for nominations,
        `posts_page` for posts) because a reviewer can be deep in one while the
        other is short; a single shared cursor would strand rows.

        **Reviewer-only, and the reviewable rule is emergent.** The endpoint is
        gated on the canonical `User#recognition_reviewer?` (a business admin, a
        Recognitions app admin, or a manager with direct reports) and refuses
        everyone else with **403 `forbidden`** rather than an empty queue that
        would read as "nothing to approve". WITHIN that gate the two personas see
        different sets, decided by data not by a role flag: an **admin** sees the
        whole tenant's queue; a **manager** sees nominations assigned to them,
        unassigned (available for any approver), or nominating one of their own
        reports, plus the posts routed to them. `viewer.is_recognition_admin`
        tells the client which it is.

        This endpoint is **read-only**. Acting on the queue is the sibling
        endpoints' job: the bulk approve/reject actions and the per-nomination
        approve/reject routes. AI-flagged content is NOT part of this queue — it
        is reviewed in the separate unified Content Moderation Queue.

        Values that represent money are reported in **reward points**; storage is
        in dollars and the conversion happens server-side.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Case-insensitive search. Matches the nominee/recipient name and
          the nomination title / post content across BOTH lists.
        schema:
          type: string
      - name: program_id
        in: query
        required: false
        description: Narrow the NOMINATIONS list to one recognition program. Posts
          have no program in this queue and are unaffected. The programs actually
          present in the queue are returned in `filters.program_options` (derived
          from the UNFILTERED queue, so switching never requires clearing the filter
          first).
        schema:
          type: integer
      - name: overdue
        in: query
        required: false
        description: When truthy, keep only NOMINATIONS submitted before the tenant's
          reminder window (the `nomination_reminder_days` setting, clamped to 1..60,
          default 7 days) — the "waiting longest" filter.
        schema:
          type: boolean
      - name: page
        in: query
        required: false
        description: 1-based page number for the NOMINATIONS list. Values below 1
          are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: posts_page
        in: query
        required: false
        description: 1-based page number for the POSTS list (independent of `page`).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, applied to both lists. Clamped to 50.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The reviewer's pending-approvals queue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approvals:
                    type: object
                    properties:
                      viewer:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          image:
                            type: string
                            nullable: true
                          is_recognition_admin:
                            type: boolean
                            description: True when this reviewer sees the WHOLE tenant's
                              queue; false for a manager scoped to their own team.
                          can_give_recognition:
                            type: boolean
                      capabilities:
                        type: object
                        description: Viewer-level toggles the client uses to render
                          the queue's global actions.
                        properties:
                          quick_award_enabled:
                            type: boolean
                          award_requests_enabled:
                            type: boolean
                          award_cycles_enabled:
                            type: boolean
                      filters:
                        type: object
                        properties:
                          applied:
                            type: object
                            description: The filter values the server actually applied
                              (echoed back for the chips).
                            properties:
                              q:
                                type: string
                                nullable: true
                              program_id:
                                type: integer
                                nullable: true
                              overdue:
                                type: boolean
                          program_options:
                            type: array
                            description: The programs present in the UNFILTERED queue
                              — the "Program" dropdown's options.
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                      nominations:
                        type: object
                        properties:
                          items:
                            type: array
                            items:
                              "$ref": "#/components/schemas/RecognitionApprovalNomination"
                          meta:
                            "$ref": "#/components/schemas/RecognitionApprovalsPageMeta"
                      posts:
                        type: object
                        properties:
                          items:
                            type: array
                            items:
                              "$ref": "#/components/schemas/RecognitionApprovalPost"
                          meta:
                            "$ref": "#/components/schemas/RecognitionApprovalsPageMeta"
                      total_pending:
                        type: integer
                        description: The whole queue this reviewer must clear — nominations
                          + posts across every page. The badge figure, matching the
                          Team endpoint's `pending_approvals.total`.
                        example: 7
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not accessible to the caller (error
            code `access_denied`), or the caller is not a reviewer (error code `forbidden`)
            — the API's answer to the web page's redirect.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/nominations":
    post:
      tags:
      - Recognitions
      summary: File a nomination (nominate someone for an award)
      description: |
        Nominate a colleague for an award — the native-client mirror of the web
        "Nominate Someone" form (RecognitionController#create_nomination), the
        composer behind the feed's **+ ▸ Nominate**. Both surfaces run ONE service
        (`Recognition::NominationSubmission`), so the eligibility gates, the
        approval routing, the reviewer assignment and the confirmation copy are
        literally the same code.

        **Who may call it.** This is the NOMINATOR half of the nominations
        controller — unlike `/approve` and `/reject`, it is NOT reviewer-gated.
        The caller needs three things, each answered up front by
        `GET /recognitions/config`:

        * the Recognitions app is accessible to them
        * award requests (Model A) are on for the tenant —
          `config.features.award_requests_enabled`
        * they may give recognition at all — `config.permissions.can_give`
          (a tenant that switched peer recognition off leaves this to managers
          and Recognition admins)

        **Build the form from `GET /recognitions/config`.** Its `programs` block
        is the program picker, with `can_nominate` and `nomination_block_reason`
        per program (hide the ones the caller can't use rather than disabling
        them) and each program's `categories`, each carrying `min_points` /
        `max_points`. `limits` states every bound this endpoint enforces, and
        `economy.points_per_dollar` is the conversion rate. The nominee picker is
        `GET /recognitions/employee_suggestions`.

        **Reward points vs stored dollars.** `requested_value` is supplied in
        reward POINTS (what the nominator types), exactly like the web form. The
        record stores dollars (1 pt = 1¢ at the default rate) and the server
        converts for you — do NOT pre-divide.

        **Approval routing.** With `require_manager_approval` off (the default)
        the nomination is filed already `approved` — nobody has to decide it, and
        it never appears in a reviewer's queue. With it on, the nomination is
        filed `under_review` and routed to the nominee's own manager (falling back
        to the tenant's first active admin), unless the tenant's autonomous
        approval ceiling clears it. Read `requires_approval` and
        `autonomously_approved` on the response rather than inferring from
        `status`.

        **Retries.** Send an `Idempotency-Key` header: a retry after a timeout
        replays the original `201` instead of filing a second nomination. Without
        one, the model's duplicate guard is the backstop — a second live
        nomination of the same person for the same award comes back `422`.

        **Body shape.** Both the web-shaped `{ "nomination": { … } }` and a flat
        `{ … }` body are accepted. Use `multipart/form-data` when attaching
        supporting evidence.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                nomination:
                  "$ref": "#/components/schemas/RecognitionNominationInput"
              required:
              - nomination
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/RecognitionNominationInput"
              - type: object
                properties:
                  supporting_files:
                    type: array
                    description: Optional evidence the approver sees beside the justification
                      — a metrics export, a customer email, a before/after screenshot.
                      Screened server-side by content type (sniffed from the bytes,
                      not trusted from the client), size and count; the exact rules
                      are reported by `config.limits.nomination_supporting_file_*`.
                    items:
                      type: string
                      format: binary
      responses:
        '201':
          description: The nomination was filed.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                - requires_approval
                properties:
                  message:
                    type: string
                    description: A human-readable outcome, mirroring the web flash
                      verbatim.
                    example: Nomination for Ada Lovelace has been submitted successfully!
                  requires_approval:
                    type: boolean
                    description: True when a reviewer still has to decide (`status`
                      is `under_review`). False means the award is already granted.
                    example: false
                  autonomously_approved:
                    type: boolean
                    description: True when the tenant DOES require manager approval
                      but this request came in under the autonomous approval ceiling
                      and was cleared without a reviewer (an Automation Hub audit
                      row is written). Lets a client explain why nobody was asked.
                    example: false
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            One of five refusals, distinguished by `error.code`:

            * `access_denied` — the Recognitions app is not accessible to the caller
            * `feature_disabled` — award requests (Model A) are off for the tenant
            * `giving_not_allowed` — the tenant limits giving to managers and
              Recognition admins, and the caller is neither
            * `program_unavailable` — the program is not open for nominations right
              now (inactive, outside its window, a lifecycle/milestone program, or
              not in this business). The picker never lists these.
            * `not_eligible` — the program IS open, just not to this nominator (a
              manager-only program, or achievement criteria they don't meet).
              `config.programs[].nomination_block_reason` says so in their words.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: The nominee is not a member of the caller's business (error
            code `nominee_not_found`) — indistinct from a user id that doesn't exist,
            so this endpoint can't be used to enumerate the directory.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The nomination failed validation (error code `validation_failed`)
            — `error.details.fields` carries the per-field messages so a client can
            mark the offending inputs. Covers self-nomination, a duplicate live nomination
            of the same person for the same award, the title and description bounds,
            a requested value outside the category's range or over the program's per-award
            cap, and rejected supporting files. A `nomination_failed` code here means
            the submit raised unexpectedly.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: validation_failed
                      message:
                        type: string
                        example: Description is too short (minimum is 10 characters)
                      details:
                        type: object
                        properties:
                          fields:
                            type: object
                            description: Per-field validation messages, keyed by attribute.
                            additionalProperties:
                              type: array
                              items:
                                type: string
                            example:
                              description:
                              - is too short (minimum is 10 characters)
        '429':
          description: The nominate rate limit (error code `rate_limited`) — 10 per
            hour per person per tenant, the SAME counter the web form is behind, so
            a client can't out-run it by switching surface. `Retry-After` and `error.details.retry_after_seconds`
            carry the window.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/nominations/bulk_approve":
    post:
      tags:
      - Recognitions
      summary: Bulk-approve recognition requests (nominations)
      description: |
        Approve MANY award-request nominations in one call — the native-client
        mirror of the web pending-approvals "Approve selected" action
        (RecognitionController#bulk_approve_nominations). Both surfaces run the
        SAME service (Recognition::BulkNominationReview), so they can't drift on
        who may bulk-approve what, on the partial-success behaviour, or on how a
        mixed result is reported.

        **Partial success, not all-or-nothing.** Each nomination is approved
        independently through Nomination#record_approval! (so a multi-level
        program advances a request to the next approver rather than minting the
        award early). One failing row does not roll back the others. The 200 body
        breaks the batch down:

        * `processed` — the nominations that were approved, each with its
          resulting `status` and (for a multi-level chain) the step it advanced to.
        * `skipped_ids` — ids that were NOT acted on because they are not
          reviewable by the caller, already decided, or belong to another tenant.
          Never silently approved.
        * `errors` — per-row failures, each naming the nominee and the reason.

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or a manager with direct reports. The
        reviewable rule then narrows the batch to the nominations this caller may
        actually decide; anything outside it comes back in `skipped_ids`.

        Up to 200 ids per call.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - nomination_ids
              properties:
                nomination_ids:
                  description: The nominations to approve. An array of ids (preferred)
                    or a comma-separated string. Blanks/zeros are dropped and duplicates
                    collapsed. 1..200 ids.
                  oneOf:
                  - type: array
                    items:
                      type: integer
                  - type: string
                  example:
                  - 8123
                  - 8124
                  - 8130
                notes:
                  type: string
                  nullable: true
                  description: Optional approval note recorded on each resulting award
                    (`reviewer_notes`).
                  example: Q3 spot-award batch — all verified with the managers.
      responses:
        '200':
          description: The batch was accepted and applied. Read `processed_count`,
            `skipped_count` and `error_count` — a 200 can still carry skipped or errored
            rows.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionBulkReviewResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not accessible to the caller (error
            code `access_denied`), or the caller is not a reviewer (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: 'The request itself was rejected before any nomination was
            touched: no ids selected (error code `no_ids`) or more than 200 ids (error
            code `too_many`).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/bulk_reject":
    post:
      tags:
      - Recognitions
      summary: Bulk-reject recognition requests (nominations)
      description: |
        Reject MANY award-request nominations in one call — the native-client
        mirror of the web pending-approvals "Reject selected" action
        (RecognitionController#bulk_reject_nominations). Both surfaces run the
        SAME service (Recognition::BulkNominationReview).

        A **reason is required** — exactly as the web modal requires it — and is
        recorded on every rejected nomination (`reviewer_notes`) and shown to each
        nominator. A blank/whitespace reason is refused with 422
        `reason_required` and nothing is touched.

        **Partial success**, reported the same way as bulk approve: `processed`
        (the rejected nominations), `skipped_ids` (not reviewable / already
        decided / another tenant's), and `errors` (per-row failures).

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or a manager with direct reports. The
        reviewable rule narrows the batch; anything outside it comes back in
        `skipped_ids`.

        Up to 200 ids per call.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - nomination_ids
              - reason
              properties:
                nomination_ids:
                  description: The nominations to reject. An array of ids (preferred)
                    or a comma-separated string. Blanks/zeros are dropped and duplicates
                    collapsed. 1..200 ids.
                  oneOf:
                  - type: array
                    items:
                      type: integer
                  - type: string
                  example:
                  - 8140
                  - 8141
                reason:
                  type: string
                  description: The reason for rejecting, recorded on every nomination
                    (`reviewer_notes`) and shown to each nominator. Required and non-blank.
                  example: These duplicate awards already granted for the same work.
      responses:
        '200':
          description: The batch was accepted and applied. Read the counts — a 200
            can still carry skipped or errored rows.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionBulkReviewResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not accessible to the caller (error
            code `access_denied`), or the caller is not a reviewer (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: 'The request itself was rejected before any nomination was
            touched: the reason is missing/blank (error code `reason_required`), no
            ids selected (error code `no_ids`), or more than 200 ids (error code `too_many`).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/{id}/approve":
    post:
      tags:
      - Recognitions
      summary: Approve a recognition request (nomination)
      description: |
        Approve an award-request nomination — the native-client mirror of the web
        approval action (RecognitionController#approve_nomination). Runs the SAME
        collaborators the web page runs, so the two surfaces cannot drift: the
        per-record ReviewableNominations authorization rule, the points→dollars
        conversion, and Nomination#record_approval!.

        On the **final** approval level this mints the Award (moving the
        recognition from "pending approval" to a real, points-bearing award) and
        notifies the recipient. Under a **multi-level** program the request is
        instead advanced to the next approver up the reporting chain, and
        `approval_complete` comes back `false` with `message` naming where it
        routed.

        **Reward points vs stored dollars:** `approved_value` is supplied in
        reward POINTS (what the reviewer sees), exactly like the web approve
        modal. The award stores dollars (1 pt = 1¢ at the default rate), and the
        server converts for you — do NOT pre-divide. Omit `approved_value` to
        approve at the originally requested value.

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or the nominee's manager (anyone with
        direct reports whose report is the nominee), AND only for a nomination the
        caller may actually review. A caller who clears the coarse gate but may
        not review this specific request gets 403.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The nomination (recognition request) id, scoped to the caller's
          business.
        schema:
          type: integer
        example: 8123
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                approved_value:
                  type: integer
                  nullable: true
                  description: The award value in reward POINTS. Overrides the requested
                    value; converted server-side to stored dollars. Omit or leave
                    blank to approve at the requested value. Must be zero or greater.
                  example: 500
                approval_notes:
                  type: string
                  nullable: true
                  description: Optional reviewer notes recorded on the nomination
                    (surfaced as `reviewer_notes`).
                  example: Clear, measurable impact on the renewal — well earned.
      responses:
        '200':
          description: The request was approved (award minted) or advanced to the
            next approval level. Read `nomination.approval_complete` to tell which.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                properties:
                  message:
                    type: string
                    description: A human-readable outcome, mirroring the web flash.
                    example: Recognition request for Ada Lovelace has been approved.
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not accessible to the caller
            (error code `access_denied`), the caller is not a reviewer at all, or
            the caller may not review THIS nomination (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: No such nomination in the caller's business (error code `not_found`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: The nomination is already processed (error code `already_processed`),
            the points value is negative (error code `invalid_points`), or the approval
            could not be completed (error code `approval_failed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/{id}/reject":
    post:
      tags:
      - Recognitions
      summary: Reject a recognition request (nomination)
      description: |
        Reject an award-request nomination — the native-client mirror of the web
        reject action (RecognitionController#reject_nomination). Runs the SAME
        collaborators the web page runs, so the two surfaces cannot drift: the
        per-record ReviewableNominations authorization rule and Nomination#reject!,
        which stamps the status, records the reviewer, stores the reason, and
        notifies the nominator.

        A **reason is required** — exactly as the web modal requires it. Send it as
        `rejection_reason` (or its alias `reason`); a blank/whitespace value is
        refused with 422 `rejection_reason_required` and the nomination is left
        untouched.

        Reject is terminal: the returned `nomination.status` is `rejected` and
        `approval_complete` is `true`. The reason is echoed back in
        `reviewer_notes`.

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or the nominee's manager (anyone with
        direct reports whose report is the nominee), AND only for a nomination the
        caller may actually review. A caller who clears the coarse gate but may not
        review this specific request gets 403.

        **Idempotent** — send an `Idempotency-Key` header and a retry after a
        timeout replays the stored response instead of re-running the reject (which
        would otherwise return 422 `already_processed` for a reject that had in
        fact succeeded).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The nomination (recognition request) id, scoped to the caller's
          business.
        schema:
          type: integer
        example: 8123
      - name: Idempotency-Key
        in: header
        required: false
        description: An opaque key that makes a retried reject replay the stored response
          instead of re-running.
        schema:
          type: string
        example: reject-8123-6f1c2a
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rejection_reason
              properties:
                rejection_reason:
                  type: string
                  description: The reason for rejecting, recorded on the nomination
                    (`reviewer_notes`) and shown to the nominator. Required and non-blank.
                  example: Duplicate of last month's award for the same work.
                reason:
                  type: string
                  description: Alias for `rejection_reason` (for native clients).
                    Whichever is present and non-blank wins.
                  example: Duplicate of last month's award for the same work.
      responses:
        '200':
          description: The request was rejected. `nomination.status` is `rejected`
            and `reviewer_notes` carries the reason.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                properties:
                  message:
                    type: string
                    description: A human-readable outcome, mirroring the web flash.
                    example: Recognition request for Ada Lovelace has been rejected.
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not accessible to the caller
            (error code `access_denied`), the caller is not a reviewer at all, or
            the caller may not review THIS nomination (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: No such nomination in the caller's business (error code `not_found`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: The rejection reason is missing/blank (error code `rejection_reason_required`),
            the nomination is already processed (error code `already_processed`),
            or the reject could not be completed (error code `rejection_failed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/posts":
    post:
      tags:
      - Recognitions
      summary: Give recognition
      description: |
        **Give peer recognition.** The native mirror of the web give composer
        (`POST /recognition/posts` → `RecognitionController#create_recognition_post`,
        reached from Feed ▸ **+** ▸ *Give recognition*) and of the mobile give form.

        Both surfaces run `::Recognition::PeerPostCreator`, so the entire gate chain
        is the same code — peer-giving access, the anti-gaming governance guard
        (monthly cap + duplicate-recipient cooldown), keyword and AI content
        moderation, the anonymity policy, peer-points gating, manager-approval and
        spend-threshold routing, and the atomic group fan-out.

        **Who may call it.** Two gates, in order:
          1. the Recognitions app is accessible to the caller (403 `access_denied`)
          2. this caller may give peer recognition (403 `giving_not_allowed`) — the
             same `::Recognition::GivingAccess` predicate behind the web guard and
             behind `permissions.can_give` on `GET /recognitions/config`. A tenant
             that switched peer recognition off leaves giving to managers and
             Recognition admins.

        **Render the composer from `GET /recognitions/config`.** Every option
        catalog and every limit this endpoint validates against is served there —
        `values`, `tags`, `cards`, `economy.point_tiers`, `visibility_options`,
        `limits.max_recipients`, `limits.message_min` / `message_max`. The
        recipient typeahead is `GET /recognitions/employee_suggestions`; a client
        should not build its own roster, because only people that query offers are
        acceptable recipients here (active members, service / AI-agent principals
        excluded).

        **A 201 does NOT mean the recognition is live.** Read `status`:

        | `status` | what happened |
        |---|---|
        | `active` | published — it is in the feed now |
        | `posting` | held for the automated content screen. **The normal path when AI moderation is on**; it publishes moments later |
        | `pending_review` | keyword/policy hold — a human moderator decides |
        | `pending_approval` | routed to the recipient's manager (manager approval, or a points give over the spend threshold) |

        `message` is the web flash for that status, word for word, so a client can
        show it as-is instead of composing its own (and getting `posting` wrong by
        claiming the recognition was sent).

        **Group gives.** `recipient_ids` may name up to `limits.max_recipients`
        people. One submission writes one row PER recipient, atomically — a failure
        on any row rolls the whole give back, so a group give never half-posts. The
        response collapses them into ONE card naming everybody (exactly as the feed
        collapses a group give) and lists every row's id in `recognition_ids`.

        **Points require a value.** `points > 0` needs a `company_value_id` — points
        are always tied to a core value. `points` is silently 0 when the tenant has
        peer points switched off, so the give lands as a plain shout-out rather
        than being refused.

        **Cards.** `award_template_id` takes the picker's selection verbatim: a
        tenant card's integer id, or `"central:<slug>"` for a gallery card (whose
        art is copied into a tenant asset on submit). Either way, the card's default
        message fills a **blank** `content` — it never overwrites what the giver
        typed. `award_art_url` on the response is the art that was attached.

        **Photos.** The desktop composer uploads a file; a native client sends
        `photo_url` instead — a **public HTTPS** URL it already hosts — and the
        server fetches the image and stores it on the recognition, so this endpoint
        stays JSON and the response card carries the same `photo_url` every read
        endpoint reports. That URL is **our** stored WebP rendition, not the one you
        sent: the bytes now live here, and the source link is never referenced
        again.

        The fetch is guarded, and every one of these refuses the give with
        `invalid_photo_url` (422) **before anything is written** — no row, no
        notification:

        | rule | why |
        |---|---|
        | public HTTPS only | loopback, private, link-local, CGNAT and cloud-metadata addresses are refused, embedded credentials are refused, and each redirect hop is revalidated |
        | JPG / PNG / WebP / HEIC | decided by **sniffing the bytes**, not by your `Content-Type` header or the URL's extension |
        | 10 MB | the same cap the web composer enforces |
        | reachable within ~10s | the fetch is inline, because a recognition that published without its photo and notified everyone cannot be un-sent |

        On a **group give** the photo is fetched and stored **once** and the blob is
        shared across every row.

        **Self-recognition** is dropped, not refused — if the caller is the only
        recipient named, the give is refused with `no_recipients`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionGiveRequest"
            examples:
              shoutOut:
                summary: Plain shout-out to one person
                value:
                  recipient_id: 812
                  content: You carried the migration all weekend — thank you.
              withPoints:
                summary: Points give (a company value is required)
                value:
                  recipient_ids:
                  - 812
                  - 907
                  content: The two of you turned a nasty outage into a non-event.
                  points: 250
                  company_value_id: 4
                  recognition_tags:
                  - teamwork
                  - above-and-beyond
                  visibility: department
              galleryCard:
                summary: Gallery card, message left blank so the card's copy is used
                value:
                  recipient_id: 812
                  award_template_id: central:thank-you-star
                  content: ''
              anonymous:
                summary: Anonymous give (only when the tenant allows it)
                value:
                  recipient_id: 812
                  content: Quietly fixed the thing nobody else wanted to touch.
                  is_anonymous: true
              withPhoto:
                summary: With a photo, named by URL
                value:
                  recipient_id: 812
                  content: Here's the install she finished single-handed on Saturday.
                  photo_url: https://cdn.example.com/uploads/install-day.jpg
      responses:
        '201':
          description: The recognition was created. Check `status` before telling
            the user it is live.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionGiveResult"
              examples:
                published:
                  summary: Published immediately
                  value:
                    recognition:
                      type: recognition_post
                      kind: recognition
                      id: 4471
                      message: You carried the migration all weekend — thank you.
                      points: 250
                      recipient:
                        id: 812
                        name: Priya Nair
                        title: SRE
                        image:
                      giver:
                        id: 55
                        name: Sofia Ahmed
                        title: Engineering Manager
                        image:
                      anonymous: false
                      company_value: Customer First
                      occurred_at: '2026-08-17T10:24:01Z'
                      visibility: public
                      status: active
                      outcome_status: active
                      tags:
                      - teamwork
                      photo_url:
                      award_art_url:
                      boost:
                        total_points: 0
                        boosted_by_me: false
                        can_boost: false
                        amounts: []
                    recognition_ids:
                    - 4471
                    status: active
                    message: Recognition for Priya Nair has been shared!
                    giving_remaining: 750
                    unread_notification_count: 3
                posting:
                  summary: Held for the automated content screen (the normal AI-moderation
                    path)
                  value:
                    recognition:
                      type: recognition_post
                      kind: recognition
                      id: 4472
                      status: pending_review
                      outcome_status: posting
                    recognition_ids:
                    - 4472
                    status: posting
                    message: Recognition for Priya Nair is posting — it'll appear
                      in the feed in a moment.
                    giving_remaining: 750
                groupGive:
                  summary: Group give — one card, one id per recipient
                  value:
                    recognition:
                      id: 4473
                      kind: recognition
                      recipient:
                        id: 812
                        name: Priya Nair
                        title: SRE
                        image:
                      group_recipients:
                      - id: 812
                        name: Priya Nair
                        title: SRE
                        image:
                      - id: 907
                        name: Marco Diaz
                        title: Platform Engineer
                        image:
                      status: active
                      outcome_status: active
                    recognition_ids:
                    - 4473
                    - 4474
                    status: active
                    message: Recognition for Priya Nair and Marco Diaz has been shared!
                    giving_remaining: 500
                withPhoto:
                  summary: With a photo — `photo_url` is the stored rendition, not
                    the URL that was sent
                  value:
                    recognition:
                      id: 4475
                      kind: recognition
                      message: Here's the install she finished single-handed on Saturday.
                      status: active
                      outcome_status: active
                      photo_url: https://officechat.workforce.mangoapps.com/rails/active_storage/representations/redirect/eyJf.../install-day.webp
                    recognition_ids:
                    - 4475
                    status: active
                    message: Recognition for Priya Nair has been shared!
                    giving_remaining: 750
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or may not give peer recognition at all (`giving_not_allowed`) — the tenant
            limits giving to managers and Recognition admins.
        '422':
          description: |
            The give was refused. `error.code` says which rule:

            | code | meaning |
            |---|---|
            | `no_recipients` | nobody was named, or the caller named only themselves |
            | `invalid_recipients` | none of the ids are people in this business (`details.recipient_ids` echoes them) |
            | `too_many_recipients` | more than `limits.max_recipients` (`details.max_recipients`) |
            | `content_missing` | no message, and no card default message to fall back on |
            | `invalid_photo_url` | `photo_url` could not be turned into a photo — not a public HTTPS URL, not reachable, not a JPG/PNG/WebP/HEIC image, or over 10 MB. `error.message` says which. **Nothing was written**, so the identical give can be retried with a different URL |
            | `governance_blocked` | the anti-gaming guard — the monthly give cap or the duplicate-recipient cooldown. `error.message` names the limit and when it lifts |
            | `content_rejected` | content moderation blocked the message outright |
            | `invalid` | a model validation — message length, a points give with no company value, an exhausted giving allowance |
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionGiveError"
              examples:
                pointsNeedValue:
                  summary: Points with no company value
                  value:
                    error:
                      code: invalid
                      message: 'Could not send recognition: Points require a company
                        value'
                tooMany:
                  summary: Over the group cap
                  value:
                    error:
                      code: too_many_recipients
                      message: You can recognize up to 50 people at once.
                      details:
                        max_recipients: 50
                governance:
                  summary: Anti-gaming guard
                  value:
                    error:
                      code: governance_blocked
                      message: You've reached your monthly recognition limit of 20.
                photoNotAnImage:
                  summary: photo_url pointed at something that isn't an image
                  value:
                    error:
                      code: invalid_photo_url
                      message: The photo URL must point to a JPG, PNG, WebP or HEIC
                        image.
                photoNotPublic:
                  summary: photo_url is not a public HTTPS URL
                  value:
                    error:
                      code: invalid_photo_url
                      message: The photo URL must be a public HTTPS image URL.
        '429':
          description: The `give_photo` rate limit (error code `rate_limited`) — 20
            per minute per person per tenant. It bounds how often this endpoint will
            issue an OUTBOUND fetch on the caller's behalf, so it is spent ONLY when
            the request carries `photo_url`; a photo-less give is never rate limited.
            Nothing is written when it trips. `Retry-After` and `error.details.retry_after_seconds`
            carry the window.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/posts/{id}":
    get:
      tags:
      - Recognitions
      summary: Recognition (peer post) detail
      description: |
        Full detail of ONE peer recognition — the screen the feed card opens.

        The feed merges two persisted tables (`RecognitionPost` and `Award`)
        whose ids collide, so the TYPE lives in the path: this route resolves a
        `RecognitionPost` ("Recognition"); awards are fetched via
        `/recognitions/awards/{id}`. Both render through the SAME serializer, so
        the card body cannot drift from the feed.

        **Engagement** carries the comment and reaction COUNTS plus the FULL list
        of every reaction (emoji + who left it) and a grouped `reaction_summary`.

        **Permissions** are per-viewer, so the client renders only affordances the
        server would honour. `can_delete` is true for the author, a business admin,
        or a Recognitions app admin; `can_edit` is the same set of people but only
        while the recognition is still ACTIVE (a held or pending post is deletable
        and not editable — see `PATCH`). `can_boost` follows the peer
        pile-on rules: the giving allowance is enabled, the viewer is neither the
        giver nor the recipient, hasn't already boosted, and has points remaining;
        the `boost` block reports the running total and the affordable amounts.

        **Enumeration-safe:** a post the caller may not see under the feed's
        visibility rules (`Recognition::PostVisibility`) 404s, indistinct from a
        missing one.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      responses:
        '200':
          description: The recognition, with engagement, permissions and boost.
          content:
            application/json:
              schema:
                type: object
                required:
                - recognition
                properties:
                  recognition:
                    "$ref": "#/components/schemas/RecognitionDetail"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (error code
            `access_denied`).
        '404':
          description: No such recognition, or one the caller may not see (error code
            `not_found`). A hidden post is deliberately indistinct from a missing
            one so ids can't be enumerated.
    patch:
      tags:
      - Recognitions
      summary: Edit a recognition (peer post)
      description: |
        The ⋯ ▸ **Edit** action on the recognition detail screen, for a peer
        recognition. Native mirror of the web edit drawer
        (`RecognitionController#edit_recognition_post` / `#update_recognition_post`)
        and of the mobile edit form — all three run the same service, so the three
        surfaces cannot drift.

        **A typo fix, not a re-give.** Only two fields move:

        | field | notes |
        |---|---|
        | `message` | the recognition text. 10–1000 characters. `content` is accepted as an alias. |
        | `company_value_id` | the value tag, from `GET /recognitions/config`'s `values`. |

        Everything else is **immutable** and silently ignored if sent —
        recipient, points, visibility, anonymity, tags, status and the award card.
        That is the point: an edit must not re-settle points, re-notify the
        recipient, re-broadcast to Slack/Teams or change who can see the post.

        **Partial update.** Only the keys present in the body are written, so a
        body carrying just `message` leaves the value tag alone. To CLEAR the tag,
        send `company_value_id` as an empty string — allowed only on a
        recognition carrying no points (a points-bearing give requires a value,
        the same rule the give form enforces, so clearing it answers 422).

        **Who:** the post's **author** (their own give) OR a **Recognitions
        moderator** (a business admin or the Recognitions app admin) — the same
        set as delete — and only while the recognition is **active**. A post held
        for review, pending manager approval, rejected or already removed answers
        **422 `not_editable`** with the reason, deliberately NOT a 403.
        `permissions.can_edit` on the detail GET and on every feed card is this
        exact predicate, so render the Edit entry only when that flag is true.

        **Moderation:** edited text goes back through the same AI content screen a
        new give hits, so an edit can't smuggle in content the create-time check
        would have caught — a rejection answers 422 `content_rejected` with the
        reason. The screen runs only when the text actually changed (a value-only
        edit makes no LLM call) and fails OPEN, so a provider outage never makes
        recognitions uneditable.

        **Response:** the SAME card `GET /recognitions/posts/{id}` returns, so a
        client replaces its row from the response instead of re-fetching.
        `unchanged: true` means the body matched what was already stored and
        nothing was written.

        `PUT` is accepted on this path with identical (partial-update) semantics.

        **Enumeration-safe:** a recognition the caller may neither edit nor even
        see 404s, indistinct from a missing one. One they can SEE but may not edit
        answers 403.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                  description: The recognition text. Surrounding whitespace is trimmed.
                    `content` is accepted as an alias for clients echoing the column
                    name.
                  example: You carried the whole migration all weekend — thank you
                    again
                company_value_id:
                  type: integer
                  nullable: true
                  description: The company value to tag, from `GET /recognitions/config`.
                    Empty string clears the tag (pointless gives only).
                  example: 12
              example:
                message: You carried the whole migration all weekend — thank you again
                company_value_id: 12
      responses:
        '200':
          description: The recognition was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
              examples:
                edited:
                  summary: Message and value updated
                  value:
                    recognition:
                      type: recognition_post
                      id: 4471
                      kind: recognition
                      message: You carried the whole migration all weekend — thank
                        you again
                      company_value: Ownership
                      points: 25
                      permissions:
                        can_edit: true
                        can_delete: true
                        can_boost: false
                    unchanged: false
                    message: Recognition updated.
                noop:
                  summary: Nothing changed (nothing was written)
                  value:
                    unchanged: true
                    message: Recognition updated.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or can see this recognition but is neither its author nor a moderator
            (`forbidden`).
        '404':
          description: No such recognition in the caller's business, or one they may
            neither edit nor see (`not_found`).
        '422':
          description: The recognition isn't editable right now — held, pending, rejected
            or removed (`not_editable`); the edited text was rejected by content moderation
            (`content_rejected`); the fields are invalid, e.g. a message under 10
            characters or clearing the value on a points-bearing give (a field-level
            `errors` array); or the save itself failed (`edit_failed`).
    put:
      tags:
      - Recognitions
      summary: Edit a recognition post — alias of PATCH
      description: Alias of `PATCH /recognitions/posts/{id}`, with identical partial-update
        semantics — fields the body omits are left alone, never cleared. Declared
        as its own operation so a generated client that only speaks `PUT` gets the
        method, matching `PUT /recognitions/comments/{id}`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                company_value_id:
                  type: integer
                  nullable: true
      responses:
        '200':
          description: The recognition was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No app access, or neither the post's author nor a moderator.
        '404':
          description: No such recognition, or one they may neither edit nor see.
        '422':
          description: Not editable (`not_editable`), rejected by content moderation
            (`content_rejected`), invalid fields, or the save failed (`edit_failed`).
    delete:
      tags:
      - Recognitions
      summary: Delete a recognition (peer post)
      description: |
        The ⋯ ▸ **Delete** action on the recognition detail screen, for a peer
        recognition. Native mirror of the web
        `RecognitionController#destroy_recognition_post` — both run the same
        service, so the two surfaces cannot drift.

        **Who:** the post's **author** (their own give) OR a **Recognitions
        moderator** (a business admin or the Recognitions app admin). The
        `permissions.can_delete` flag on `GET /recognitions/posts/{id}` and on
        every feed card is this exact predicate, so the client should render the
        Delete entry only when that flag is true.

        **What it does:** a SOFT delete (`status` → `deleted`). The recognition
        leaves the feed and the recipient's profile and the row is retained for
        the audit trail; the model reverses the points itself — a settled give
        hands the recipient's credited points back and refunds the giver's spent
        allowance, an unsettled one releases the giver's reservation. The author
        is notified, with the optional `reason` included.

        **Group gives:** one submission to N recipients is N rows shown as ONE
        card. This removes only the addressed row — that recipient is
        un-recognized, the rest of the group is untouched — matching the web.

        **Idempotent:** deleting an already-deleted recognition succeeds with
        `already_deleted: true` and touches nothing (no second points reversal).

        **Enumeration-safe:** a recognition the caller may neither delete nor even
        see 404s, indistinct from a missing one. A recognition they can SEE but
        may not delete answers 403, so a client that raced a permission change
        gets a real reason instead of "gone".
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      - name: reason
        in: query
        required: false
        description: Optional note carried into the author's deletion notice — typically
          supplied when a moderator removes someone else's recognition. May also be
          sent in a JSON body.
        schema:
          type: string
          maxLength: 500
        example: Posted to the wrong person
      responses:
        '200':
          description: The recognition was removed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionDeletion"
              examples:
                deleted:
                  summary: Removed
                  value:
                    id: 4471
                    type: recognition_post
                    deleted: true
                    status: deleted
                    already_deleted: false
                    message: Recognition removed.
                    unread_notification_count: 3
                alreadyDeleted:
                  summary: Already removed (idempotent re-issue)
                  value:
                    id: 4471
                    type: recognition_post
                    deleted: true
                    status: deleted
                    already_deleted: true
                    message: Recognition was already removed.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or can see this recognition but is neither its author nor a moderator
            (`forbidden`).
        '404':
          description: No such recognition in the caller's business, or one they may
            neither delete nor see (`not_found`).
        '422':
          description: The removal itself failed — points reversal or notification
            errored (`deletion_failed`).
  "/recognitions/awards/{id}":
    get:
      tags:
      - Recognitions
      summary: Award / Certificate detail
      description: |
        Full detail of ONE award — the native mirror of the web award permalink
        (RecognitionController#award_show) and its printable certificate.

        This route resolves an `Award`. The user perceives TWO kinds here, both
        served by this one endpoint and told apart by the `kind` field:
          * `award` — a human-given program/cycle/nomination award.
          * `certificate` — an AUTOMATED award (service anniversary, birthday,
            milestone; `award_metadata.triggered_by` present). Its giver presents
            as "System (Automated)" and it can never be boosted — there is nothing
            to pile onto on a system-generated certificate. It IS still an Award,
            so a moderator may revoke or edit one exactly as they may any award;
            clients typically hide the ⋯ menu on a certificate anyway, and
            `can_delete` / `can_edit` report the honest server capability rather
            than that UI choice.

        Awards support reactions and comments identically to posts (same
        `engagement` block, same FULL reactions list). Awards are NOT boostable,
        so `boost` is null. `award_art_url` is the gold-framed certificate page.

        **Permissions:** awards have no boost (`can_boost` false). Both write
        capabilities are moderation acts held by the same actor — a Recognitions
        moderator (a business admin or the Recognitions app admin) while the award
        is still active: `can_delete` maps to the admin REVOKE
        (`DELETE /recognitions/awards/{id}`) and `can_edit` to the citation /
        value fix (`PATCH /recognitions/awards/{id}`). Never the giver's own and
        never the recipient's. There is no age limit on either: the web has never
        applied `Award#can_be_revoked?`'s 30-day window, so neither does this.

        **Enumeration-safe:** a non-public award the caller neither gave nor
        received (and isn't an admin for) 404s, mirroring
        RecognitionController#load_visible_award.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
        example: 991
      responses:
        '200':
          description: The award (or automated certificate), with engagement and permissions.
          content:
            application/json:
              schema:
                type: object
                required:
                - recognition
                properties:
                  recognition:
                    "$ref": "#/components/schemas/RecognitionDetail"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (error code
            `access_denied`).
        '404':
          description: No such award, or one the caller may not see (error code `not_found`).
    patch:
      tags:
      - Recognitions
      summary: Edit a recognition (award / certificate)
      description: |
        The ⋯ ▸ **Edit** action on the recognition detail screen when the card is an
        Award (or an automated Certificate, which is an Award). Runs the same
        service as the peer-post PATCH above.

        Editing an award is a **moderation** act, so — unlike a peer post, which
        its author may fix — this is **moderator only**: a business admin or the
        Recognitions app admin, never the giver's own touch-up and never the
        recipient's. That is the same actor rule as the revoke on this path,
        because an award's citation is program-level copy rather than one person's
        words. Only an **active** award is editable; a revoked or expired one
        answers **422 `not_editable`**.

        **Two fields move:**

        | field | notes |
        |---|---|
        | `message` | the award citation — the `description` column, which is what the card serializes as `message`. 10–1000 characters. `description` is accepted as an alias. |
        | `company_value_id` | the value tag, from `GET /recognitions/config`'s `values`. |

        Everything else is **immutable** and silently ignored if sent: `title`,
        `value` (the points/amount), recipient, giver, program, category,
        `is_public` and `status`. An edit therefore never re-credits store points,
        never redraws a manager's group budget and never changes who can see the
        award.

        **Partial update, response and enumeration safety** are exactly as
        documented for `PATCH /recognitions/posts/{id}` — only the keys present are
        written, the response is the same card the detail GET returns plus
        `unchanged` / `message`, and an award the caller may neither edit nor see
        404s. `PUT` is accepted with identical semantics.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
        example: 991
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                  description: The award citation (the `description` column). Surrounding
                    whitespace is trimmed. `description` is accepted as an alias.
                  example: Delivered the Q3 platform migration two weeks ahead of
                    plan
                company_value_id:
                  type: integer
                  nullable: true
                  description: The company value to tag, from `GET /recognitions/config`.
                  example: 12
              example:
                message: Delivered the Q3 platform migration two weeks ahead of plan
      responses:
        '200':
          description: The award was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
              examples:
                edited:
                  summary: Citation updated
                  value:
                    recognition:
                      type: award
                      id: 991
                      kind: award
                      title: Excellence in Delivery
                      message: Delivered the Q3 platform migration two weeks ahead
                        of plan
                      permissions:
                        can_edit: true
                        can_delete: true
                        can_boost: false
                    unchanged: false
                    message: Recognition updated.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or can see this award but is not a Recognitions moderator (`forbidden`)
            — including its own giver and its recipient.
        '404':
          description: No such award in the caller's business, or one they may neither
            edit nor see (`not_found`).
        '422':
          description: The award is no longer editable — revoked or expired (`not_editable`);
            the edited citation was rejected by content moderation (`content_rejected`);
            the fields are invalid, e.g. a citation under 10 characters (a field-level
            `errors` array); or the save itself failed (`edit_failed`).
    put:
      tags:
      - Recognitions
      summary: Edit a recognition (award / certificate) — alias of PATCH
      description: Alias of `PATCH /recognitions/awards/{id}`, with identical partial-update
        semantics — fields the body omits are left alone, never cleared. Declared
        as its own operation so a generated client that only speaks `PUT` gets the
        method, matching `PUT /recognitions/comments/{id}`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                company_value_id:
                  type: integer
                  nullable: true
      responses:
        '200':
          description: The recognition was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No app access, or the caller is not a Recognitions moderator.
        '404':
          description: No such award, or one they may neither edit nor see.
        '422':
          description: Not editable (`not_editable`), rejected by content moderation
            (`content_rejected`), invalid fields, or the save failed (`edit_failed`).
    delete:
      tags:
      - Recognitions
      summary: Delete a recognition (award / certificate) — admin revoke
      description: |
        The ⋯ ▸ **Delete** action on the recognition detail screen when the card is
        an Award (or an automated Certificate, which is an Award). Native mirror of
        the web `RecognitionController#revoke_award`.

        For an award, "delete" is the admin **REVOKE**: `status` → `revoked`, the
        giver's group budget is refunded and the recipient's store points are
        reversed. The recipient is notified. `reason` is recorded on the award
        (`award_metadata.revoked_reason`); when omitted it defaults to
        "Revoked by {caller name}", so the notice always names a person.

        **Who:** a **Recognitions moderator** only — a business admin or the
        Recognitions app admin. Never the giver's own undo and never the
        recipient's. Only an **active** award can be revoked; there is no age
        limit (`Award#can_be_revoked?`'s 30-day window has never been enforced on
        the web, so it isn't enforced here either).

        This is the same predicate `permissions.can_delete` reports on
        `GET /recognitions/awards/{id}`.

        **Enumeration-safe:** an award the caller may neither revoke nor see 404s.
        Re-issuing the request on an already-revoked award answers **422
        `not_revocable`** — deliberately NOT a 403, which would be both wrong and
        unactionable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
        example: 991
      - name: reason
        in: query
        required: false
        description: Why the award is being revoked. Stored on the award and included
          in the recipient's notice. Defaults to "Revoked by {caller name}". May also
          be sent in a JSON body.
        schema:
          type: string
          maxLength: 500
        example: Awarded to the wrong person
      responses:
        '200':
          description: The award was revoked.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionDeletion"
              examples:
                revoked:
                  summary: Revoked
                  value:
                    id: 991
                    type: award
                    deleted: true
                    status: revoked
                    already_deleted: false
                    message: Award revoked. Dana Marsh's points were reversed.
                    unread_notification_count: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or is not a Recognitions moderator (`forbidden`).
        '404':
          description: No such award in the caller's business, or one they may neither
            revoke nor see (`not_found`).
        '422':
          description: The award is not active, so there is nothing to revoke — `not_revocable`
            (e.g. it was already revoked or has expired). A revoke that itself failed
            answers `deletion_failed`.
  "/recognitions/awards/{id}/certificate":
    get:
      tags:
      - Recognitions
      summary: Award certificate — the "View Certificate" overlay for one award
      description: |
        One award's gold-framed **Certificate of Recognition** — the native mirror
        of the printable web page (`/recognition/awards/{id}/card`) and the overlay
        behind the **View Certificate** button on the Award Results screen.

        Returns the certificate's fields in the order the printed page renders
        them: the award title, the recipient, who awarded it, the italic citation
        quote, the program / points / company-value chips, then the footer's
        issuing organization and date. The fixed copy the page prints around those
        fields — the gold eyebrow, "is proudly presented to", the "awarded by"
        prefix, the QR caption — is in `meta`, so the client hardcodes no strings.

        **The same block the results screen embeds.** `certificate` is byte-identical
        to `winners[].certificate` in
        `GET /recognitions/award_cycles/{id}/results`; both come from one
        serializer. A client that already holds the results payload can render the
        overlay from it and refresh from here, and the two can never disagree.

        **Why the endpoint exists at all**, given the results payload carries it:
        a certificate is reachable from places that hold an **award id and no
        cycle** — its own scan-to-view QR and share link, a push deep link, a My
        Recognition or feed row — and from awards that never came from a cycle at
        all: a manager Quick Award, a nomination award, or an automated lifecycle
        certificate (an anniversary or milestone, which the feed labels
        "Certificate"). This is the award-keyed lookup for all of them.

        **Not gated on award cycles.** Certificates are not a Model B feature, and
        the awards above exist in tenants that never enabled cycles — gating here
        would 403 the majority of certificates. Access is gated only the way every
        endpoint in this namespace is: the Recognitions app must be enabled for the
        tenant and the caller must be inside its audience.

        **Two different URLs, deliberately.** `certificate_url` is the printable
        page for Print / Save; `share_url` is the award permalink — what a Share
        action sends and what the QR should encode. Encoding the print page would
        dead-end a phone that scanned it in a print dialog.

        **Opening your own certificate acknowledges it**, silently and with no
        button, exactly as both web award surfaces do — the mobile client's
        confetti stops on the strength of this read. Only the recipient's own read
        writes anything; a giver, an admin or a colleague opening the same
        certificate writes nothing.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The **award's** id — `certificate.award_id` on any winner card,
          or the `id` of any `award` / `certificate` row in the feed. Scoped to the
          caller's tenant.
        example: 23
      responses:
        '200':
          description: The certificate for one award
          content:
            application/json:
              schema:
                type: object
                required:
                - certificate
                - meta
                properties:
                  certificate:
                    "$ref": "#/components/schemas/RecognitionAwardCycleCertificate"
                  meta:
                    "$ref": "#/components/schemas/RecognitionCertificateMeta"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
        '404':
          description: |
            No certificate to serve, as one indistinguishable answer (error code
            `not_found`) for all of:

            * no award with that id in the caller's tenant
            * an award the caller may not see — the canonical rule is public, or
              the caller is its recipient or giver, or the caller is an admin
            * an award that is no longer **active** (revoked, expired or deleted),
              which stops being printable on the web page too — including for its
              own recipient

            The collapse is deliberate. A distinct 403 would confirm that a
            private award with that id exists, which is exactly what a caller
            walking the id space is trying to learn.
  "/recognitions/comments":
    get:
      tags:
      - Recognitions
      summary: List the comments on a recognition / award / certificate
      description: |
        The comment thread of ONE recognition — the native mirror of the web
        thread (RecognitionController#fetch_comments).

        Recognition comments are polymorphic (`RecognitionComment#commentable`),
        so ONE endpoint serves BOTH commentable kinds, selected by `item_type`:
          * `recognition_post` — a peer recognition post.
          * `award` — a program/cycle/nomination award.
          * `certificate` — accepted as an ALIAS of `award` (a certificate is a
            display variant of an award, not a separate commentable). The response
            normalizes `item_type` back to `award`.

        Returns only ACTIVE comments, oldest-first, paginated. Each comment carries
        its author, content, reaction summary, and per-viewer `can_edit`/`can_delete`
        flags — the SAME predicates the write endpoints enforce, so a client never
        renders a control the API would refuse.

        Threads are ONE level deep. `comments` holds only TOP-LEVEL rows; a reply
        is inlined under the comment it answers, in that comment's `replies` array
        (and carries `parent_id`). A reply is never also a top-level row, so the
        client draws the thread exactly as returned. `meta.total_count` therefore
        counts TOP-LEVEL comments — it is the page count for this list, and is
        smaller than the recognition detail's `comments_count`, which counts every
        visible row (top-level plus their active replies).

        **Enumeration-safe:** a recognition the caller may not see under the feed's
        visibility rules 404s, indistinct from a missing one — so the thread of a
        private/team/department recognition can't be read by guessing ids.
      security:
      - BearerAuth: []
      parameters:
      - name: item_type
        in: query
        required: true
        description: The commentable kind — `recognition_post`, `award`, or `certificate`
          (alias of `award`).
        schema:
          type: string
          enum:
          - recognition_post
          - award
          - certificate
      - name: item_id
        in: query
        required: true
        description: The recognition post / award id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Page size (default 20, clamped to 50).
        schema:
          type: integer
          default: 20
          maximum: 50
      responses:
        '200':
          description: The active comment thread, oldest-first, paginated.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_type
                - item_id
                - comments
                - meta
                properties:
                  item_type:
                    type: string
                    enum:
                    - recognition_post
                    - award
                    description: The canonical commentable type (the `certificate`
                      alias is normalized to `award`).
                  item_id:
                    type: integer
                    example: 4471
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionComment"
                  meta:
                    "$ref": "#/components/schemas/RecognitionCommentPageMeta"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (error code
            `access_denied`).
        '404':
          description: No such recognition, or one the caller may not see (error code
            `not_found`).
        '422':
          description: "`item_type` was not one of the accepted values (error code
            `invalid_item_type`)."
    post:
      tags:
      - Recognitions
      summary: Add a comment to a recognition / award / certificate
      description: |
        Post a comment on a recognition post or award — the native mirror of
        RecognitionController#comment.

        `item_type` selects the commentable (`recognition_post` / `award` /
        `certificate`-as-alias). Content is validated (1–500 characters) and run
        through the tenant's content-moderation policy: when the policy HOLDS the
        comment, it is created hidden pending review and the response returns
        `held: true` so the client can show the same "submitted for review" state
        the web does.

        `parent_id` (optional) posts a threaded REPLY to an existing comment on
        the SAME recognition — the one-level threading the web feed offers.
        A `parent_id` naming a reply is re-pointed at that reply's top-level
        parent (threads never nest deeper); a `parent_id` that names no ACTIVE
        comment on this recognition is ignored and the text posts as a top-level
        comment rather than being rejected.

        Requires the tenant's comments toggle to be on; otherwise `403`
        (`comments_disabled`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_type
              - item_id
              - content
              properties:
                item_type:
                  type: string
                  enum:
                  - recognition_post
                  - award
                  - certificate
                item_id:
                  type: integer
                  example: 4471
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
                  example: Fantastic work — well deserved!
                parent_id:
                  type: integer
                  nullable: true
                  example: 991
                  description: Optional. The TOP-LEVEL comment on this same recognition
                    that this one replies to. Omit (or send null) for a top-level
                    comment. A reply's own id is re-pointed at its parent — threads
                    are one level deep — and an id that matches no active comment
                    on this recognition is ignored rather than rejected.
      responses:
        '201':
          description: The created comment (hidden if moderation held it).
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                - held
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecognitionComment"
                  held:
                    type: boolean
                    description: True when the tenant's moderation policy held the
                      comment for review (created hidden).
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or comments
            are disabled for the tenant (`comments_disabled`).
        '404':
          description: No such recognition, or one the caller may not see (`not_found`).
        '422':
          description: Blank/too-long content, or an invalid `item_type`.
  "/recognitions/comments/{id}":
    patch:
      tags:
      - Recognitions
      summary: Edit a comment or reply
      description: |
        Edit the text of a recognition comment. **Author-only** — only the person
        who wrote a comment may reword it (an admin may delete but not rewrite
        someone else's words). Editing re-runs content moderation and stamps
        `edited_at`, so the `edited` flag flips true. `PUT` is accepted as an alias
        of `PATCH`.

        **Replies are edited by this same route**, addressed by the reply's own
        `id`. Author-only applies to the reply's own author: owning the top-level
        comment a reply hangs under does NOT confer the right to reword that reply.

        Only `content` is editable. A `parent_id` sent in the body is **ignored** —
        a comment cannot be re-parented, so a reply can never be moved under a
        different thread after the fact, and the response's `parent_id` is always
        the one it was created with.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The comment id, scoped to the caller's business.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
                  example: Edited — thank you again!
      responses:
        '200':
          description: The updated comment.
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                - held
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecognitionComment"
                  held:
                    type: boolean
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller is not the comment's author (error code `forbidden`),
            or has no access to the Recognitions app, or comments are disabled.
        '404':
          description: No such comment in the caller's business, or its recognition
            is not visible (`not_found`).
        '422':
          description: Blank/too-long content.
    put:
      tags:
      - Recognitions
      summary: Edit a comment (alias of PATCH)
      description: Alias of `PATCH /recognitions/comments/{id}`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
      responses:
        '200':
          description: The updated comment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecognitionComment"
                  held:
                    type: boolean
        '403':
          description: Not the author, no app access, or comments disabled.
        '404':
          description: No such comment, or its recognition is not visible.
        '422':
          description: Blank/too-long content.
    delete:
      tags:
      - Recognitions
      summary: Delete a comment or reply
      description: |
        Remove a recognition comment. Allowed for the comment's **author** OR a
        **Recognitions moderator** (a business admin or the Recognitions app admin)
        — the same widening the feed uses for `can_delete` on a post.

        Soft-deletes (status → `deleted`): the comment leaves the active thread and
        the recognition's comment count is recomputed, while the row is retained for
        the audit trail.

        **Replies are deleted by this same route**, addressed by the reply's own
        `id`, under the same author-or-moderator rule applied to the reply's own
        author. Deleting a reply leaves its parent — and the rest of the thread —
        in place.

        **Deleting a top-level comment takes its replies out of view with it.** A
        reply renders only underneath its parent, so once the parent is gone the
        replies render nowhere: they are omitted from `GET /recognitions/comments`
        and shed from the recognition's `comments_count` along with the parent (one
        parent with one reply drops the count by 2). The reply rows themselves stay
        `active` in the audit trail and are **never promoted to top-level** — so a
        client must not expect them to reappear as standalone comments.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The comment id, scoped to the caller's business.
        schema:
          type: integer
      responses:
        '200':
          description: The comment was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - deleted
                properties:
                  id:
                    type: integer
                    example: 991
                  deleted:
                    type: boolean
                    example: true
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller is neither the author nor a moderator (error code
            `forbidden`).
        '404':
          description: No such comment in the caller's business, or its recognition
            is not visible (`not_found`).
  "/recognitions/posts/{id}/boost":
    post:
      tags:
      - Recognitions
      summary: Boost a recognition (pile-on points)
      description: |
        Add **5, 10 or 25 points from your own monthly giving allowance** to
        somebody else's recognition — the native mirror of the web feed's
        ⋯ ▸ **Boost** (`RecognitionController#boost_recognition_post`). Both
        surfaces run the same `Recognition::BoostService`, so the amounts, the
        allowance spend and the eligibility rules cannot drift.

        It **settles immediately**: the amount leaves the caller's allowance and
        lands in the recipient's store balance, and the recipient is notified.
        There is no undo — a boost is a one-tap celebration, not a draft.

        **Peer recognition posts ONLY.** Awards, certificates and milestone
        recognitions are not boostable — that is why the path is `/posts/{id}`
        and there is no `/awards/{id}/boost` twin. `GET /recognitions/awards/{id}`
        reports `permissions.can_boost: false` for the same reason.

        **Who may call it** — four gates, in the order the server applies them:

        | # | Gate | Failure |
        |---|------|---------|
        | 1 | The Recognitions app is accessible to the caller | `403 access_denied` |
        | 2 | The tenant has peer giving points switched on | `403 boosting_disabled` |
        | 3 | The caller may give peer recognition (everyone when the tenant's peer toggle is on; managers and admins always) | `403 forbidden` |
        | 4 | The post exists in the caller's business AND is visible to them | `404 not_found` |

        Past those, the service refuses with `422`: your own give, a recognition
        you received, an amount outside 5/10/25, a second boost on the same post,
        an inactive recipient, or an allowance that can't cover the amount
        (`insufficient_allowance`).

        **Don't guess whether the caller may boost** — read the `boost` block on
        `GET /recognitions/feed` or `GET /recognitions/posts/{id}`: `can_boost`
        and `amounts` are computed by the same rules this endpoint enforces, and
        `amounts` is already capped to the caller's remaining allowance.

        **Enumeration-safe:** a post in another tenant, or one this caller may not
        see, returns `404` — indistinct from a missing one, so ids can't be probed
        to discover that a private recognition exists.

        The success payload carries the post's REFRESHED `boost` block and the
        caller's new `giving_remaining`, so a client patches the card it already
        has instead of re-fetching the feed. Every `422` carries the same two
        values under `error.details`, so a client whose card was stale (someone
        else's boost landed first, the wallet ran dry in another tab) can
        reconcile straight from the refusal.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition POST id (`RecognitionPost`), scoped to the caller's
          business.
        schema:
          type: integer
        example: 4471
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - points
              properties:
                points:
                  type: integer
                  enum:
                  - 5
                  - 10
                  - 25
                  description: The boost amount, spent from the caller's monthly giving
                    allowance. Anything else is refused with `invalid_boost`.
                  example: 10
      responses:
        '201':
          description: The boost was applied and settled.
          content:
            application/json:
              schema:
                type: object
                required:
                - boost
                - recognition
                - giving_remaining
                properties:
                  boost:
                    "$ref": "#/components/schemas/RecognitionBoostReceipt"
                  recognition:
                    "$ref": "#/components/schemas/RecognitionBoostTarget"
                  giving_remaining:
                    type: integer
                    description: The caller's remaining monthly giving allowance AFTER
                      this boost.
                    example: 990
                  message:
                    type: string
                    description: The same confirmation the web flashes — safe to show
                      verbatim.
                    example: Boosted Dana Wu's recognition with 10 points. You have
                      990 left this month.
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), the tenant
            has peer giving points switched off (`boosting_disabled`), or this caller
            may not give peer recognition (`forbidden`).
        '404':
          description: No such recognition post in the caller's business, or one they
            may not see (`not_found`).
        '422':
          description: The boost was refused — `invalid_boost` (your own give, a recognition
            you received, an amount outside 5/10/25, an inactive recipient, or you
            have already boosted this post) or `insufficient_allowance` (your remaining
            allowance can't cover the amount). Nothing is written and no points move.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionBoostError"
  "/recognitions/reactions":
    get:
      tags:
      - Recognitions
      summary: Who reacted to a recognition
      description: "The people who reacted to ONE recognition, most recent first —
        the\npaginated form of the `engagement.reactions` list that\n`GET /recognitions/posts/{id}`
        ships inline. Reach for it when a\nrecognition has more reactions than a detail
        payload should carry, or to\nshow \"everyone who sent \U0001F389\" with `?emoji=`.\n\n**One
        polymorphic endpoint for every reactable kind**, keyed by\n`item_type` + `item_id`,
        exactly as the comment thread is — reactions live\nin `platform_reactions`
        and the association is polymorphic, so a path per\nkind would be pure duplication.\n\n|
        `item_type` | Resolves | Notes |\n|---|---|---|\n| `recognition_post` | A
        peer recognition | |\n| `award` | An award or certificate | |\n| `certificate`
        | *alias of* `award` | A certificate is an Award rendered with a certificate
        template |\n| `comment` | A comment on either | |\n\nThe response always echoes
        the **canonical** `item_type`, so a client that\nposted `certificate` can
        still key its cache off the response alone.\n\nA client holding a **comment
        id** can use the comment-scoped twin instead —\n`GET /recognitions/comments/{id}/reactions`
        — which answers identically\nwithout needing the `(item_type, item_id)` pair.\n\nAlongside
        the page, the response carries a **`summary`** block: per-emoji\ncounts plus
        the caller's own glyphs, computed over the **whole set** rather\nthan the
        page, so a reactor sheet's per-emoji tabs need no second\nround-trip and their
        counts don't shrink as the reader pages. `?emoji=`\nnarrows the **list** only
        — never the summary, because the tabs must keep\nshowing the glyphs the reader
        can switch *to*.\n\n**Not gated on the tenant's reaction switch.** Turning
        reactions off\nretires the affordance; it does not retract the reactions people
        already\nleft, and the detail screen keeps showing them. Read `reactions_enabled`\nfrom
        `GET /recognitions/config` (or from a write response) to decide\nwhether to
        render the bar.\n\n**Enumeration-safe:** a recognition in another tenant,
        or one this caller\nmay not see, returns `404` — indistinct from a missing
        one.\n\nCosts a **constant number of queries** however many people reacted:
        the\npage is one indexed read plus one batched avatar load.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: item_type
        in: query
        required: true
        description: Which kind of recognition the reactions belong to.
        schema:
          type: string
          enum:
          - recognition_post
          - award
          - certificate
          - comment
        example: recognition_post
      - name: item_id
        in: query
        required: true
        description: The id of that recognition, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      - name: emoji
        in: query
        required: false
        description: Narrow the list to a single glyph. Omit for every reaction.
        schema:
          type: string
        example: "\U0001F44D"
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Capped at 100.
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: The reactions on this recognition, most recent first.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_type
                - item_id
                - reactions
                - summary
                - meta
                properties:
                  item_type:
                    type: string
                    enum:
                    - recognition_post
                    - award
                    - recognition_comment
                    description: The CANONICAL type, even when the request used an
                      alias.
                  item_id:
                    type: integer
                    example: 4471
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionReactor"
                  summary:
                    "$ref": "#/components/schemas/RecognitionReactionSummary"
                  meta:
                    "$ref": "#/components/schemas/RecognitionReactionsPage"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
        '422':
          description: "`item_type` is not a recognition kind (`invalid_item_type`).
            `error.details.allowed` lists the ones that are."
    post:
      tags:
      - Recognitions
      summary: React to a recognition (toggle)
      description: "**Capture a reaction** on a recognition post, an award/certificate,
        or a\ncomment on either — the native mirror of the web reaction bar\n(`POST
        /recognition/reactions/toggle` → `RecognitionController#react`).\nBoth surfaces
        run the same `Recognition::ReactionService`, so the tenant\nswitch, the accepted
        kinds, the audience gate, the emoji allowlist and the\nrate limit cannot drift.\n\n**TOGGLE
        semantics**, matching the web bar and\n`Platform::Reactable#toggle_reaction`:
        re-sending an emoji the caller\nalready left **removes** it; otherwise it
        adds it. `reacted` says which way\nthis call went, and the status line says
        it too — **`201` when a reaction\nwas added, `200` when one was removed**.
        A viewer may hold several\n*different* emojis at once; each is its own row,
        toggled independently.\nWhen you need \"off, whatever the current state is\",
        use `DELETE` — it is\nidempotent and a retry can never flip the reaction back
        on.\n\n**Send an `Idempotency-Key` header on a retry-prone connection.** Without\none,
        a retried POST toggles a second time and silently undoes the first.\nWith
        one, the stored response is replayed (`X-Idempotency-Cached: true`)\nand nothing
        is written twice.\n\n**The emoji allowlist is per model** — `Platform::Reactable`'s\n`reactable_emoji_set`.
        Recognition posts, awards and recognition comments\nall accept **\U0001F44D
        ❤️ \U0001F389 \U0001F44F ⭐ \U0001F525**; anything else is refused with\n`invalid_emoji`,
        and `error.details.allowed` carries the set so a client\ncan correct itself
        without a second round-trip.\n\n**Who may call it** — four gates, in the order
        the server applies them:\n\n| # | Gate | Failure |\n|---|------|---------|\n|
        1 | The Recognitions app is accessible to the caller | `403 access_denied`
        |\n| 2 | The caller is inside the shared 30/minute `react` bucket — the SAME
        counter the web bar spends, so a client can't out-run the web limit by switching
        surface | `429 rate_limited` (with `Retry-After`) |\n| 3 | The tenant has
        reactions switched on | `403 reactions_disabled` |\n| 4 | The recognition
        exists in the caller's business AND is visible to them | `404 not_found` |\n\n**Enumeration-safe:**
        a recognition in another tenant, a private give the\ncaller isn't part of,
        and a missing id all return the same `404`.\n\nThe success payload carries
        the recognition's **refreshed reaction block**,\nkeyed exactly as `engagement`
        is keyed on `GET /recognitions/feed` and\n`GET /recognitions/posts/{id}` —
        so a client patches the card it already\nhas instead of re-fetching the screen.
        It is computed with two aggregate\nqueries, so a recognition with a thousand
        reactions costs the same as one\nwith three.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A client-generated key. A retry carrying the same key replays
          the stored response instead of toggling again.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_type
              - item_id
              - emoji
              properties:
                item_type:
                  type: string
                  enum:
                  - recognition_post
                  - award
                  - certificate
                  - comment
                  description: Which kind of recognition to react to. `certificate`
                    aliases `award`; `comment` resolves a comment on either. The web
                    bar's class-name spellings (`RecognitionPost`, `Award`, `RecognitionComment`)
                    are accepted too.
                  example: recognition_post
                item_id:
                  type: integer
                  description: The id of that recognition, scoped to the caller's
                    business.
                  example: 4471
                emoji:
                  type: string
                  enum:
                  - "\U0001F44D"
                  - "❤️"
                  - "\U0001F389"
                  - "\U0001F44F"
                  - "⭐"
                  - "\U0001F525"
                  description: The glyph to toggle. Must be in the target model's
                    `reactable_emoji_set`.
                  example: "\U0001F389"
      responses:
        '201':
          description: The reaction was ADDED.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '200':
          description: 'The caller already held that emoji, so the toggle REMOVED
            it (`reacted: false`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`). Nothing is written.
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
        '422':
          description: The reaction was refused — `invalid_item_type` (not a recognition
            kind), `emoji_required` (blank), or `invalid_emoji` (outside the model's
            allowlist). `error.details.allowed` carries the accepted values. Nothing
            is written.
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
            The `Retry-After` header and `error.details.retry_after_seconds` both
            carry the window. Nothing is written.
    delete:
      tags:
      - Recognitions
      summary: Remove your reaction (idempotent)
      description: |
        Remove the caller's own `emoji` from one recognition — the unambiguous half
        of the toggle above.

        **Idempotent.** Removing a reaction that isn't there succeeds with
        `removed: false`, so a client retrying a dropped request never flips the
        reaction back on the way a repeated `POST` would. This is the call to use
        when your UI knows the target state ("off") rather than the transition.

        **Scoped to the caller's own row** — it can never remove somebody else's
        reaction, and it leaves the caller's *other* emojis on the same recognition
        alone.

        Same four gates as `POST`, in the same order, including the shared
        30/minute `react` bucket. Answers with the same refreshed reaction block,
        plus `removed`.
      security:
      - BearerAuth: []
      parameters:
      - name: item_type
        in: query
        required: true
        schema:
          type: string
          enum:
          - recognition_post
          - award
          - certificate
          - comment
        example: recognition_post
      - name: item_id
        in: query
        required: true
        schema:
          type: integer
        example: 4471
      - name: emoji
        in: query
        required: true
        description: The glyph to remove.
        schema:
          type: string
        example: "\U0001F389"
      responses:
        '200':
          description: The caller holds no such reaction any more. `removed` says
            whether this call is what removed it.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/RecognitionReactionState"
                - type: object
                  properties:
                    removed:
                      type: boolean
                      description: True when this call deleted a row; false when there
                        was nothing to delete (still a success — the endpoint is idempotent).
                      example: true
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`).
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
        '422':
          description: "`invalid_item_type`, `emoji_required`, or `invalid_emoji`."
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
  "/recognitions/comments/{id}/reactions":
    parameters:
    - name: id
      in: path
      required: true
      description: The comment's id, scoped to the caller's business. A comment on
        a peer recognition, on an award/certificate, or a reply — a reply is just
        a comment with a parent and reacts on this same path.
      schema:
        type: integer
      example: 88213
    get:
      tags:
      - Recognitions
      summary: Who reacted to a comment
      description: |
        **Fetch the reactions on ONE comment** in a recognition thread, most
        recent first — the comment-scoped twin of
        `GET /recognitions/reactions`, in the shape the News Feed API uses for the
        same case (`GET /comments/{id}/reactions`). Reach for it when you already
        hold a comment id: a client rendering a thread has comment ids, not
        `(item_type, item_id)` pairs.

        It is a **path, not a second implementation.** This endpoint and
        `/recognitions/reactions` run the same action body over the same
        `Recognition::ReactionService` the **web comment row's reaction bar** runs
        (`shared/recognition/_feed_comment` → `shared/_platform_reaction_bar` →
        `POST /recognition/reactions/toggle`), so the three surfaces cannot
        disagree. Passing `?item_type=comment&item_id={id}` to
        `/recognitions/reactions` returns a byte-identical body.

        Alongside the page it carries a **`summary`** block — per-emoji counts plus
        the caller's own glyphs — computed over the **whole set** rather than the
        page, so a reactor sheet's per-emoji tabs need no second round-trip and
        their counts don't shrink as the reader pages. `?emoji=` narrows the
        **list** only, never the summary: the tabs must keep showing the glyphs the
        reader can switch *to*.

        **Not gated on the tenant's reaction switch.** Turning reactions off
        retires the affordance; it does not retract what people already left, and
        the web thread keeps showing them. `summary.reactions_enabled` reports the
        switch so a client reading only this endpoint still knows to hide the bar.

        **The audience gate is the comment's PARENT recognition** — the same rule
        the web thread applies. A comment in another tenant, one hanging off a
        private recognition this caller isn't part of, and a missing id all return
        the same `404`, so ids can't be enumerated to discover a private thread.

        Costs a **constant number of queries** however many people reacted: one
        indexed page read, one batched avatar load, two aggregates for the summary.
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: false
        description: Narrow the LIST to a single glyph. Omit for every reaction.
        schema:
          type: string
        example: "\U0001F44D"
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Capped at 100.
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: The reactions on this comment, most recent first.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_type
                - item_id
                - reactions
                - summary
                - meta
                properties:
                  item_type:
                    type: string
                    enum:
                    - recognition_comment
                    description: Always `recognition_comment` — the kind is fixed
                      by the path.
                  item_id:
                    type: integer
                    description: The comment's id, echoing the path.
                    example: 88213
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionReactor"
                  summary:
                    "$ref": "#/components/schemas/RecognitionReactionSummary"
                  meta:
                    "$ref": "#/components/schemas/RecognitionReactionsPage"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such comment in the caller's business, or one whose parent
            recognition they may not see (`not_found`).
    post:
      tags:
      - Recognitions
      summary: React to a comment (toggle)
      description: "**Capture a reaction** on one comment in a recognition thread
        — the native\nmirror of the web comment row's own reaction bar\n(`shared/recognition/_feed_comment`
        renders `shared/_platform_reaction_bar`\nwith the comment as the reactable,
        posting to\n`POST /recognition/reactions/toggle`). Both surfaces run the same\n`Recognition::ReactionService`,
        so the tenant switch, the audience gate,\nthe emoji allowlist and the rate
        limit cannot drift.\n\n**TOGGLE semantics**, matching the web bar: re-sending
        an emoji the caller\nalready left **removes** it; otherwise it adds it. `reacted`
        says which way\nthis call went, and the status line says it too — **`201`
        when a reaction\nwas added, `200` when one was removed**. A viewer may hold
        several\n*different* emojis on the same comment, each toggled independently.
        When\nyou need \"off, whatever the current state is\", use `DELETE`.\n\n**Send
        an `Idempotency-Key` header on a retry-prone connection.** Without\none, a
        retried POST toggles a second time and silently undoes the first.\nWith one,
        the stored response is replayed (`X-Idempotency-Cached: true`).\n\nComments
        accept **\U0001F44D ❤️ \U0001F389 \U0001F44F ⭐ \U0001F525**. Anything else
        is refused with\n`invalid_emoji`, and `error.details.allowed` carries the
        set.\n\n**Who may call it** — four gates, in the order the server applies
        them:\n\n| # | Gate | Failure |\n|---|------|---------|\n| 1 | The Recognitions
        app is accessible to the caller | `403 access_denied` |\n| 2 | The caller
        is inside the shared 30/minute `react` bucket — ONE counter across this path,
        `/recognitions/reactions` and the web bar, so a client can't out-run the limit
        by switching path | `429 rate_limited` (with `Retry-After`) |\n| 3 | The tenant
        has reactions switched on | `403 reactions_disabled` |\n| 4 | The comment
        exists in the caller's business AND its parent recognition is visible to them
        | `404 not_found` |\n\nNote gate 4 is about **reactions**, not comments: turning
        the tenant's\n*comments* switch off stops new comments, it does not freeze
        the reactions\non a thread already there — exactly as the web row behaves.\n\nThe
        success payload carries the comment's **refreshed reaction block**,\nkeyed
        exactly as `reaction_summary` is keyed everywhere else in this\nnamespace,
        so a client patches the comment row it already holds instead of\nre-fetching
        the thread. Two aggregate queries, so a comment with a thousand\nreactions
        costs the same as one with three.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A client-generated key. A retry carrying the same key replays
          the stored response instead of toggling again.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  enum:
                  - "\U0001F44D"
                  - "❤️"
                  - "\U0001F389"
                  - "\U0001F44F"
                  - "⭐"
                  - "\U0001F525"
                  description: The glyph to toggle.
                  example: "\U0001F44F"
      responses:
        '201':
          description: The reaction was ADDED.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '200':
          description: 'The caller already held that emoji, so the toggle REMOVED
            it (`reacted: false`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`). Nothing is written.
        '404':
          description: No such comment in the caller's business, or one whose parent
            recognition they may not see (`not_found`).
        '422':
          description: "`emoji_required` (blank) or `invalid_emoji` (outside the allowlist).
            `error.details.allowed` carries the accepted glyphs. Nothing is written."
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
            The `Retry-After` header and `error.details.retry_after_seconds` both
            carry the window. Nothing is written.
    delete:
      tags:
      - Recognitions
      summary: Remove your reaction from a comment (idempotent)
      description: |
        Remove the caller's own `emoji` from one comment — the unambiguous half of
        the toggle above.

        **Idempotent.** Removing a reaction that isn't there succeeds with
        `removed: false`, so a client retrying a dropped request never flips the
        reaction back on the way a repeated `POST` would. This is the call to use
        when your UI knows the target state ("off") rather than the transition.

        **Scoped to the caller's own row** — it can never remove somebody else's
        reaction, and it leaves the caller's *other* emojis on the same comment
        alone.

        Same four gates as `POST`, in the same order, including the shared
        30/minute `react` bucket. Answers with the same refreshed reaction block,
        plus `removed`.
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: true
        description: The glyph to remove.
        schema:
          type: string
        example: "\U0001F44F"
      responses:
        '200':
          description: The caller holds no such reaction any more. `removed` says
            whether this call is what removed it.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/RecognitionReactionState"
                - type: object
                  properties:
                    removed:
                      type: boolean
                      description: True when this call deleted a row; false when there
                        was nothing to delete (still a success — the endpoint is idempotent).
                      example: true
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`).
        '404':
          description: No such comment in the caller's business, or one whose parent
            recognition they may not see (`not_found`).
        '422':
          description: "`emoji_required` or `invalid_emoji`."
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
  "/recognitions/posts/{id}/acknowledge":
    post:
      tags:
      - Recognitions
      summary: Settle a recognition celebration (peer recognition)
      description: |
        **"I've celebrated this one."** `GET /api/v1/apps` carries
        `unacknowledged_recognitions` on the Recognitions node — the recognitions
        the caller has RECEIVED and not yet seen. The client fires its confetti on
        app open and then settles each item here, so the next launch is quiet.

        **The implicit half needs no call.** Opening a recognition's detail
        already acknowledges it (`GET /recognitions/posts/{id}`, and the web +
        mobile-web detail screens) — silently, reporting nothing. Use this path
        for the case with no detail view in it: the app celebrated on the launcher
        and the user never tapped through.

        **Silent.** Acknowledging writes one timestamp: no notification, no feed
        change, no `updated_at` bump, nothing the giver or anyone else can
        observe.

        **NOT-THE-RECIPIENT IS A `200`, NOT AN ERROR.** A colleague — or a
        moderator, or the giver — calling this on a recognition they can see gets
        `acknowledged: false` with a diagnostic `reason` and no write. A
        recognition the caller may not SEE still `404`s, exactly as the detail GET
        does, so ids can't be enumerated to discover a private give.

        **Group gives resolve to the caller's OWN row.** One submission naming
        five people is five rows sharing a `recognition_group_id`, and every
        surface collapses them to one card — so four of those five people hold an
        id whose recipient is somebody else. This endpoint stamps the caller's own
        sibling row, and the echoed `id` is the row that was stamped (which may
        differ from the `id` in the path).

        **One item per call, and idempotent.** A repeat call answers
        `acknowledged: true, newly_acknowledged: false` — `newly_acknowledged` is
        the flag a client keys the animation off, so re-opening never
        re-celebrates. There is deliberately no bulk path: a client settling two
        or three celebrations makes two or three calls.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post's id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      responses:
        '200':
          description: 'The acknowledgement state after this call. `acknowledged:
            false` when the caller is not the person recognized — an ordinary no-op,
            not an error.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionAcknowledgement"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
  "/recognitions/awards/{id}/acknowledge":
    post:
      tags:
      - Recognitions
      summary: Settle a recognition celebration (award / certificate)
      description: |
        The award half of `POST /recognitions/posts/{id}/acknowledge` — same rule,
        same silence, same `200`-for-a-non-recipient contract, same idempotency.

        Split by type because `RecognitionPost` and `Award` ids collide, exactly as
        the detail GETs are split (`GET /recognitions/posts/{id}` vs
        `GET /recognitions/awards/{id}`). The `type` in the response is the one the
        pending list emits, so the `type` a client is given is the `type` it reads
        back.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award's id, scoped to the caller's business.
        schema:
          type: integer
        example: 812
      responses:
        '200':
          description: 'The acknowledgement state after this call. `acknowledged:
            false` when the caller is not the person recognized — an ordinary no-op,
            not an error.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionAcknowledgement"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such award in the caller's business, or one they may not
            see (`not_found`).
  "/company-store/approvals":
    get:
      tags:
      - Company Store
      summary: Redemption approval queue
      description: |
        The Approvals tab: every redemption held for this caller's decision,
        paginated, plus the badge figure a client needs beside the tab.

        **Approvers and store admins only** — see the authorization note on this
        file's other operations and the four gates listed there. A caller who
        holds no queue gets `403 not_an_approver` (or `403 approvals_delegated`
        when a designated approver group has superseded their org-chart route),
        never an empty queue.

        ### The pool

        `Store::RedemptionApprovalService.approval_queue_for` — the ONE
        definition, which picks between two shared pools on the same precedence
        `.can_approve?` itself applies:

        * **store admin** → `.admin_queue_for`: every `pending_approval` order in
          the tenant, at **every** approval tier. Shared verbatim with the admin
          orders page's "Redemptions Awaiting Approval" section, so the two lists
          cannot drift.
        * **everyone else** → `.manager_queue_for`: `pending_approval` orders
          routed to the **manager** tier only, narrowed to the caller's own
          reports (or the whole tenant for an approver-group member). Shared with
          the web queue page, the web dashboard's pending-count banner and the
          native dashboard's Team Approvals widget.

        Three private copies of "the manager queue" is exactly how the dashboard
        once counted holds the queue page then refused, so nothing about either
        pool is re-derived here.

        ### `total_pending` — the badge figure

        The FULL queue depth, independent of `page` / `per_page`, on **every**
        response including the two decision endpoints. So a client renders its tab
        badge from the same call that drew the list, and the badge updates from
        the same response that took a decision — no second request, and no window
        where the badge disagrees with the screen.

        ### Ordering

        `sort=oldest` (the default) is longest-waiting first — the order a queue
        should be worked in, and what the dashboard widget shows; those are the
        requests an approver is holding up. `sort=newest` matches the web table.
        An unrecognised value falls back to the default rather than erroring.

        ### `scope`

        `company` for a store admin or a designated approver-group member (their
        queue spans the tenant) or `team` for a line manager (their own reports).
        The web page renders two different subtitles off exactly this distinction
        — calling a committee's rows "your team" mislabelled every one of them.

        ### Cost

        Flat per page. The requester, their department, the item and both
        attachment chains (requester avatar, product thumbnail) are batched in one
        pass, so a page of 50 rows costs the same queries as a page of 2. The
        per-row `can_approve` costs at most ONE extra query for the whole page —
        and none at all unless the caller is a Company Store app admin who is not
        a business admin, the only persona whose pool is wider than their
        approvable set.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based page number. `0` / negative / junk is treated as page
          1.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        description: Rows per page, clamped to 50 server-side — an unbounded `per_page`
          is how one client turns a paginated endpoint into a full-table read. A non-positive
          or junk value falls back to 20. `filters.per_page` reports what was actually
          applied.
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - oldest
          - newest
          default: oldest
        description: "`oldest` (default) — longest-waiting first, the order a queue
          is worked in and what the dashboard's Team Approvals widget uses. `newest`
          — the web table's own order. An unrecognised value falls back to `oldest`,
          and `filters.sort` reports which was applied."
      responses:
        '200':
          description: Queue retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  approvals:
                    "$ref": "#/components/schemas/CompanyStoreApprovalQueue"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every native response for badge management.
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `read:company_store`
            — checked first, before any of the gates below), `access_denied` (Company
            Store not accessible to this user), `store_disabled` (the tenant paused
            the store), `approvals_delegated` (a designated approver group has superseded
            this line manager) or `not_an_approver` (this caller has no approval queue)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
  "/company-store/approvals/{id}/approve":
    post:
      tags:
      - Company Store
      summary: Approve a held redemption
      description: |
        Releases the hold for fulfillment and notifies the employee — the native
        equivalent of the web queue's **Approve** button.

        Runs `Store::RedemptionApprovalService.approve!`, the same call the web
        button makes, which flips the order to `pending`, records the approver on
        the order, dispatches fulfillment (gift cards fulfill inline; physical /
        print-on-demand enqueue to the provider) and notifies the redeemer
        in-app/push. **The points stay spent** — `points_refunded` is `0`.

        Only for a hold in **this** caller's own queue: an `id` they cannot even
        see answers `404`, not `403`, because from this endpoint's point of view
        it is not an approvable request at all. A hold they CAN see but may not
        decide — the admin branch's `can_approve: false` rows — answers `403
        not_an_approver`.

        The response carries the recomputed `total_pending`, so a client's tab
        badge updates from the same call that took the decision.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The StoreOrder id, as reported by each queue row's `id` (and
          by the dashboard widget's `team_approvals.requests[].id`) — the same identifier
          the web queue's own buttons post to. Digits only.
      responses:
        '200':
          description: Approved and released for fulfillment
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalDecisionResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`),
            `access_denied`, `store_disabled`, `approvals_delegated` or `not_an_approver`
            — see the queue endpoint. `not_an_approver` also covers the per-ROW refusal:
            the caller holds a queue and this hold is in it, but they may not decide
            THIS one (`can_approve: false`)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '404':
          description: "`not_found` — that redemption isn't in your approval queue
            (it was never held, it is held at a different tier, or it belongs to somebody
            outside your team)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '409':
          description: "`decision_failed` — the hold moved out from under you between
            drawing the queue and deciding (already approved or declined by someone
            else, or re-tiered so it now routes to a different approver). The request
            was well-formed and you ARE an approver, so a client should refresh the
            queue rather than retry."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
  "/company-store/approvals/{id}/decline":
    post:
      tags:
      - Company Store
      summary: Decline a held redemption
      description: |
        Declines the redemption, **refunds the held points to the employee** and
        restocks the item — the native equivalent of the web queue's **Decline
        redemption** modal.

        Runs `Store::RedemptionApprovalService.reject!`, the same call the web
        modal makes: the order is cancelled (which returns the points to the
        employee's wallet, and to the team store budget it was drawn from if any,
        and increments the item's inventory back) and the employee is notified.
        `points_refunded` reports what went back.

        `reason` is **optional but employee-facing**: the redeemer sees it in
        their notification, so send the one the approver typed. With none, the
        service records a generic "not approved by &lt;approver&gt;". A
        whitespace-only reason is treated as none given. (The web modal requires a
        reason before enabling its submit button; the native reject sheet has
        none, so the server accepts both.)

        Only for a hold in **this** caller's own queue — same `403` / `404` /
        `409` semantics as `approve`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The StoreOrder id, as reported by each queue row's `id` (and
          by the dashboard widget's `team_approvals.requests[].id`) — the same identifier
          the web queue's own buttons post to. Digits only.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why it was declined. The EMPLOYEE sees this — send
                    the approver's own words. Optional; blank/absent records a generic
                    "not approved by <approver>".
                  example: This item is out of budget for this quarter
      responses:
        '200':
          description: Declined; points refunded and inventory restocked
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalDecisionResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`),
            `access_denied`, `store_disabled`, `approvals_delegated` or `not_an_approver`
            — see the queue endpoint. `not_an_approver` also covers the per-ROW refusal:
            the caller holds a queue and this hold is in it, but they may not decide
            THIS one (`can_approve: false`)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '404':
          description: "`not_found` — that redemption isn't in your approval queue."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '409':
          description: "`decision_failed` — already decided, or re-routed to a different
            approver."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
  "/libraries/list":
    get:
      tags:
      - Libraries
      summary: All Libraries (index)
      description: |
        The native-client mirror of the web **All Libraries** index
        (`Apps::LibrariesController#show`), shaped to the Libraries design
        mockup's home screen. One row carries everything a card needs — cover,
        mark, kind, both counts, last-updated and the two capability flags — so
        rendering a screen costs ONE call and no per-row follow-ups.

        Both surfaces resolve the list through the same `Libraries::IndexQuery`,
        so the API and the web page cannot drift on which libraries are visible,
        what "Admin order" means, or where disabled libraries sit.

        ### What the caller sees

        * **A Libraries admin** (business admin, super admin, or a per-app
          Libraries admin) sees every library in the tenant, **disabled ones
          included** — they are badged, not hidden, because this is the surface
          that offers Enable. `can_disable` is `true`.
        * **Everyone else** sees only ENABLED libraries whose audience admits
          them: `visibility: all_users` libraries, plus any whose audience rules
          match them by user, department, group, location, job family, job title,
          organizational role or platform role. A disabled library is never
          visible to them under any filter. `can_disable` is `false`.

        Visibility narrows the rows, `filter_counts` AND `meta.total_count`
        together, so every chip badge counts exactly what clicking it returns.

        ### Ordering

        **Disabled libraries are ALWAYS LAST, under every sort** — they are
        admin-only, and a hidden library taking a slot among the live ones pushes
        real content down the page. The requested `sort` orders rows *within* the
        enabled and disabled groups. The rule lives in the SQL `ORDER BY`, so it
        holds across page boundaries: disabled rows land on the last page, never
        at the bottom of page 1.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: 'Which chip is selected. `disabled` is ADMIN-ONLY — a disabled
          library is invisible to everyone else, so a member requesting it falls back
          to `all` (200, with `active_filter: "all"`) rather than being refused. An
          unknown value also falls back to `all`. Read `available_filters` to know
          which chips to draw.'
        schema:
          type: string
          enum:
          - all
          - disabled
          default: all
      - name: sort
        in: query
        required: false
        description: |
          The order to list in — the mockup's three options:

          * `admin` — **Admin order**: the order admins set in the web Reorder
            dialog (the stored `position`), then name.
          * `az` — **A → Z**: by name ascending, case-insensitively.
          * `recent` — **Recently updated**: newest `updated_at` first. This is
            the same timestamp the card's `updated_at` carries, so the sort and
            the label can never disagree.

          `updated` is accepted as a DEPRECATED alias of `recent` (the mobile
          prototype's spelling); the response always echoes the **canonical**
          key in `sort`, so a client can tell which order it actually got. An
          unknown value falls back to `admin`.
        schema:
          type: string
          enum:
          - admin
          - az
          - recent
          default: admin
      - name: page
        in: query
        required: false
        description: 1-based. A missing, zero, negative, non-numeric or collection-shaped
          value is 1. A page past the end is an empty 200, not an error.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page, capped at 50. A value that does not parse as a
          positive integer (`0`, `-3`, `abc`, `?per_page[]=5`) is treated as ABSENT
          and falls back to 20 — deliberately NOT floored to 1, which would hand a
          malformed client a one-row-per-page walk of the whole list.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Library list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - libraries
                - active_filter
                - sort
                - available_filters
                - available_sorts
                - filter_counts
                - can_disable
                - meta
                properties:
                  libraries:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LibraryCard"
                  active_filter:
                    type: string
                    enum:
                    - all
                    - disabled
                    description: The filter actually applied — which is not always
                      the one requested (an unknown value, or `disabled` from a member,
                      resolves to `all`).
                    example: all
                  sort:
                    type: string
                    enum:
                    - admin
                    - az
                    - recent
                    description: The order actually applied, always as the CANONICAL
                      key (a `?sort=updated` request answers `recent`).
                    example: admin
                  available_filters:
                    type: array
                    description: The chips THIS caller may select — `["all", "disabled"]`
                      for a Libraries admin, `["all"]` for everyone else. Draw the
                      chip row from this rather than hard-coding the admin-only rule.
                    items:
                      type: string
                    example:
                    - all
                    - disabled
                  available_sorts:
                    type: array
                    description: The three orders, in the order the menu shows them.
                    items:
                      type: string
                    example:
                    - admin
                    - az
                    - recent
                  filter_counts:
                    type: object
                    description: Total per available filter, for the chip badges.
                      Counted over the SAME visible set the rows come from, so each
                      badge equals the `meta.total_count` that filter returns. A non-admin
                      payload OMITS the `disabled` key entirely (not a zero) — that
                      chip was never offered.
                    properties:
                      all:
                        type: integer
                        example: 8
                      disabled:
                        type: integer
                        description: Admin only. Absent for other callers.
                        example: 2
                  can_disable:
                    type: boolean
                    description: Whether this caller may Disable / Enable a library
                      — the gate behind the row menu's Disable option and behind `POST
                      /libraries/{id}/disable`. App-wide, not per-library, so it is
                      also mirrored onto every card.
                    example: true
                  meta:
                    "$ref": "#/components/schemas/LibraryListMeta"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:libraries`;
            or `access_denied` — the Libraries app is not enabled for the tenant,
            or this user is outside the app's audience."
  "/libraries/search":
    get:
      tags:
      - Libraries
      summary: Search libraries, categories and items
      description: |
        The native-client mirror of the web search on the **All Libraries** page
        (`Apps::LibrariesController#show` with `?q=`), shaped to the mobile
        prototype's inline results. Both surfaces resolve through the same
        `Libraries::SearchQuery`, so the API and the web page cannot drift on
        what matches, what ranks first, or whose libraries are searched.

        ### What is searched

        Three record types, each on its own fields — echoed back in
        `searched_fields` so a client need not hard-code them:

        | Type | Fields |
        |------|--------|
        | Libraries  | `name`, `description` |
        | Categories | `name` (the table has no description) |
        | Items      | `title`, `description` |

        An item is **not** matched by its library's or its category's name. A
        query like `company` therefore returns the library *Company Policies*
        plus the items whose own title or description says "company" — not all
        28 items that happen to live in it, which would be rows with nothing in
        them to show for the match.

        ### Ranking

        Every row carries the `search_score` it was ordered by:

        * library — 3 per `name` hit + 1 per `description` hit, **+ 6**
        * category — 3 per `name` hit, **+ 3**
        * item — 2 per `title` hit + 1 per `description` hit

        The two bumps put places above items and libraries above categories,
        which is the order the mockups present them in. **Multi-term is OR**:
        `safety harness` matches rows containing either word, ranked by how many
        they match.

        ### What the caller sees

        Results are drawn from the libraries this caller may see, and
        **DISABLED LIBRARIES ARE EXCLUDED FOR EVERYONE** — including admins.
        A disabled library is hidden from everyone but an admin and excluded
        from search; the All Libraries index is where an admin finds it (badged,
        with the Enable control). Everyone else sees `visibility: all_users`
        libraries plus any whose audience rules match them.

        ### Pagination

        **Only `items` paginates.** Libraries and categories are bounded by the
        tenant's own structure (tens of rows) and every mockup shows all of them
        above the items, so `meta` describes the ITEM page while `counts`
        reports all three totals and `counts_total` their sum.

        **And because they do not paginate, `libraries` and `categories` are
        sent on PAGE 1 ONLY.** From `page=2` on, both are `[]` — not because
        nothing matched, but because you already have them. Re-sending them per
        item page was pure repetition (measured 2026-09-05: `?q=a&per_page=1`
        returned byte-identical `libraries` and `categories` blocks on pages
        1, 2 and 3, ~98% of each page's payload), and a client that APPENDS the
        two arrays as it pages — the natural shape for the infinite scroll this
        `meta` exists to drive — rendered every library once per page.

        **How to tell "not resent" from "genuinely zero":** read `counts`, which
        is computed off the match relations rather than off these arrays and so
        carries all three totals on EVERY page. `libraries: []` with
        `counts.libraries > 0` means "already sent on page 1"; with
        `counts.libraries == 0` it means nothing matched. Never infer either
        total from an array's length.

        ### Truncation is stated, never silent

        The two unpaginated lists are capped at **100 rows each**
        (`Libraries::SearchQuery::MAX_PLACE_RESULTS`) so a one-character `q`
        cannot be a single-request dump of every library and category name in
        the tenant. `libraries_truncated` and `categories_truncated` say when a
        cap actually bit, so a client is never handed a partial list it thinks
        is complete — the same contract, and the same key names, as the sibling
        `GET /libraries/{id}` (`categories_truncated`, `items_truncated`).

        Both flags are derived from the CEILING, not from the array length:
        `counts.<type> > 100`. That is why they stay honest on page 2, where the
        arrays are empty by design and a length comparison would claim
        truncation for every two-library search.

        ### Not offered

        The mobile facet sheet (library / item type / updated / review) is not
        implemented: the web search this mirrors has no facets, and two of the
        four have nothing behind them here — `review` is content governance (a
        different model with its own surface) and `updated` is a bucket the
        prototype derives from a hand-written string. A filter that silently
        matched nothing would be worse than none.

        Item rows DO carry the caller's own bookmark state — `bookmarked` and
        the `bookmark` sub-object — resolved in one query for the page, never
        one per row. (Until 2026-09-05 they did not: the serializer's `bookmark`
        argument defaulted to nil, so every search row reported
        `bookmarked: false` while `GET /libraries/bookmarks` and
        `GET /libraries/{id}` said true for the same item in the same minute,
        and a client rendering its kebab from this flag offered "Save" for an
        item already saved.)
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        description: The search string. Whitespace-separated terms are OR'd and scored
          by how many match; terms beyond the tenth are dropped (the response's `terms`
          reports what was actually used). `%`, `_` and `\` are searched literally,
          not as wildcards. Blank or whitespace-only is a 400 — a client asking for
          nothing is a client with a bug, and an empty 200 would hide it.
        schema:
          type: string
          minLength: 1
          example: safety harness
      - name: sort
        in: query
        required: false
        description: |
          * `relevance` — by `search_score` descending, then name/title. The
            default, and the only order the mockups show.
          * `recent` — newest `updated_at` first, within each record type.

          `updated` is accepted as an alias of `recent` (the mobile
          prototype's spelling); the response echoes the **canonical** key in
          `sort`. An unknown value falls back to `relevance`.
        schema:
          type: string
          enum:
          - relevance
          - recent
          default: relevance
      - name: page
        in: query
        required: false
        description: |-
          1-based, and applies to `items` only. A missing, zero, negative, non-numeric or collection-shaped value is 1. A page past the end is an empty `items` array with a 200, not an error.
          ANY page but the first also answers `libraries: []` and `categories: []` — those two lists do not paginate and are therefore sent once, on page 1 (see Pagination above). `counts` is unaffected and reports all three totals on every page, which is how a client tells "not resent" from "nothing matched".
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, capped at 50. A value that does not parse as
          a positive integer (`0`, `-3`, `abc`) is treated as ABSENT and falls back
          to 20 — deliberately NOT floored to 1.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                required:
                - query
                - terms
                - sort
                - available_sorts
                - searched_fields
                - counts
                - counts_total
                - libraries
                - categories
                - libraries_truncated
                - categories_truncated
                - items
                - meta
                properties:
                  query:
                    type: string
                    description: The query as searched — the request's `q`, trimmed.
                    example: safety harness
                  terms:
                    type: array
                    description: 'The terms actually used: whitespace-split, de-duplicated
                      case-insensitively, capped at ten. A client highlighting matches
                      should use THESE, not its own split of `query`.'
                    items:
                      type: string
                    example:
                    - safety
                    - harness
                  sort:
                    type: string
                    enum:
                    - relevance
                    - recent
                    description: The order actually applied, always as the CANONICAL
                      key (a `?sort=updated` request answers `recent`).
                    example: relevance
                  available_sorts:
                    type: array
                    items:
                      type: string
                    example:
                    - relevance
                    - recent
                  searched_fields:
                    type: array
                    description: The fields this search covered, `group.column`. Read
                      it rather than hard-coding the list — it comes from the same
                      constants the SQL is built from.
                    items:
                      type: string
                    example:
                    - libraries.name
                    - libraries.description
                    - categories.name
                    - items.title
                    - items.description
                  counts:
                    "$ref": "#/components/schemas/LibrarySearchCounts"
                  counts_total:
                    type: integer
                    description: |-
                      Every match across all three types — the sum of `counts`, and the number the web results head with ("18 results"). NOT the item page's total, which is `meta.total_count`.
                      NAMED `counts_total`, NOT `total_count`, since 2026-09-05. Both names lived in this one body meaning different numbers: measured live, `?q=brand` answered root 3 / `meta.total_count` 1, and `?q=a` answered root 227 / meta 205 — so a client reading "total_count" got a different answer depending on how deep it looked, and a generic paginator keyed on the root value asked for a 12th page of an 11-page list. A client written against the old name must read `counts_total` (or sum `counts` itself).
                    example: 18
                  libraries:
                    type: array
                    description: |-
                      Matching libraries, ranked — the same card the index returns, plus `search_score`.
                      **PAGE 1 ONLY.** This list does not paginate, so it is sent once and every later page answers `[]`. An empty array is NOT a statement about matches: read `counts.libraries`, which is present on every page, to tell "already sent" from "nothing matched". Capped at 100 rows, with `libraries_truncated` saying when the cap bit.
                    items:
                      allOf:
                      - type: object
                        description: One row of the All Libraries index. Every field
                          the mockup's card renders, with the same fallbacks the web
                          card applies — so the two surfaces cannot drift on an icon,
                          a colour or a type label.
                        required:
                        - id
                        - title
                        - description
                        - image
                        - banner_fill
                        - icon
                        - color
                        - library_type
                        - library_type_label
                        - categories_count
                        - items_count
                        - updated_at
                        - enabled
                        - can_disable
                        - link
                        properties:
                          id:
                            type: integer
                            example: 42
                          title:
                            type: string
                            description: The library's name.
                            example: Company Policies
                          description:
                            type: string
                            nullable: true
                            description: The card's blurb. `null` (never `""`) when
                              the library has none.
                            example: Every published company policy, grouped by the
                              team that owns it.
                          image:
                            type: string
                            nullable: true
                            description: ABSOLUTE URL of the library's banner image,
                              or `null` when it has none — in which case paint `banner_fill`.
                              Absolute because a native client cannot resolve a host-relative
                              path.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf.../banner.png
                          banner_fill:
                            type: string
                            description: 'The CSS gradient the web card paints behind
                              a library with no banner image. Sent whole rather than
                              derived client-side: the light stop is a hand-picked
                              per-preset value, so `color` alone is not enough to
                              reproduce it.'
                            example: linear-gradient(110deg,#3f5372,#7c8fb0)
                          icon:
                            type: string
                            description: Font Awesome class for the mark on the banner.
                              Falls back to `fas fa-book-open` — the same default
                              the web card uses — so this is never blank.
                            example: fas fa-file-shield
                          color:
                            type: string
                            description: The library's hex colour, driving both the
                              icon tint and the banner fill. Falls back to the app
                              default (`#2f64b1`), so this is never blank and never
                              a non-hex value.
                            example: "#3f5372"
                          library_type:
                            type: string
                            enum:
                            - mixed_content
                            - images_and_videos
                            description: The library kind, for client logic.
                            example: mixed_content
                          library_type_label:
                            type: string
                            description: The display wording the banner prints. Provided
                              because `titleize` gets it wrong — `images_and_videos`
                              reads "Images & Videos", not "Images And Videos".
                            example: Mixed Content
                          categories_count:
                            type: integer
                            description: Number of categories in this library (the
                              folder icon on the card). Computed by a scalar subquery,
                              so it costs nothing per row.
                            example: 6
                          items_count:
                            type: integer
                            description: Number of items across all of this library's
                              categories (the document icon on the card). Also a scalar
                              subquery.
                            example: 28
                          updated_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: ISO8601. The same timestamp `sort=recent`
                              orders on and the card renders as "Updated N ago".
                            example: '2026-08-29T06:32:39Z'
                          enabled:
                            type: boolean
                            description: '`false` for a disabled library — draw the
                              "Disabled" badge and grey the row. Only a Libraries
                              admin ever receives a `false` here.'
                            example: true
                          can_disable:
                            type: boolean
                            description: Whether this caller may Disable / Enable
                              this library. App-wide, so identical on every row of
                              a response and equal to the top-level `can_disable`;
                              carried per-row so a client rendering one card in isolation
                              needs no extra context.
                            example: true
                          link:
                            type: string
                            nullable: true
                            description: ABSOLUTE web URL of the library — for the
                              row menu's Copy link, and for opening the library in
                              a webview.
                            example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/42
                      - type: object
                        properties:
                          search_score:
                            type: integer
                            description: The score this row was ranked by.
                            example: 10
                  categories:
                    type: array
                    description: 'Matching categories, ranked. **PAGE 1 ONLY**, on
                      the same terms as `libraries` above: `[]` from page 2 on, with
                      `counts.categories` as the total that stays truthful on every
                      page. Capped at 100 rows, with `categories_truncated` saying
                      when the cap bit.'
                    items:
                      "$ref": "#/components/schemas/LibrarySearchCategory"
                  libraries_truncated:
                    type: boolean
                    description: |-
                      True when more than 100 libraries matched (`Libraries::SearchQuery::MAX_PLACE_RESULTS`) and the `libraries` array therefore holds only the first 100. Present on EVERY page, and derived from `counts.libraries > 100` rather than from the array's length — which is what keeps it honest on page 2, where the array is empty by design.
                      Read it. It exists so a client is never silently handed a partial list; nothing else in the response says the list was clipped.
                    example: false
                  categories_truncated:
                    type: boolean
                    description: The same flag for `categories`, against the same
                      100-row ceiling and the same `counts.categories > 100` derivation.
                    example: false
                  items:
                    type: array
                    description: One page of matching items, ranked. The same card
                      `GET /libraries/bookmarks` returns (same serializer), plus `search_score`.
                      `bookmarked` and `bookmark` carry the CALLER'S OWN saved state
                      here, resolved in one query for the page — they are not the
                      constant false/null this endpoint returned before 2026-09-05.
                    items:
                      allOf:
                      - type: object
                        description: One saved library item — everything a row, its
                          3-dot sheet and its details panel need, from one call.
                        required:
                        - id
                        - title
                        - item_type
                        - item_type_label
                        - icon_type
                        - icon
                        - breadcrumb
                        - status
                        - open_mode
                        - bookmarked
                        properties:
                          id:
                            type: integer
                            description: The LIBRARY ITEM id — what the bookmark write
                              endpoints take.
                            example: 412
                          title:
                            type: string
                            example: Remote Work Policy
                          description:
                            type: string
                            nullable: true
                          item_type:
                            type: string
                            description: The stored item kind. Branch logic on this,
                              not on `item_type_label`.
                            enum:
                            - simple_link
                            - file
                            - form
                            - survey
                            - wiki
                            - post
                            - image
                            - video
                            example: file
                          item_type_label:
                            type: string
                            description: |-
                              Display label, EXTENSION-specific where the app can tell ("PDF", "Word", "Excel") and type-level otherwise ("Link", "Form", "Wiki"). The same label the web row prints.
                              NAMED `item_type_label`, NOT `type_label`, since 2026-09-05: the column is `item_type`, every other label in this repo is `<prefix>_type_label` (including `library_type_label` on the library card and `item_type_label` on `GET /libraries/{id}`), and the bare spelling meant one concept arrived under two names depending on which Libraries endpoint a client called.
                            example: PDF
                          icon_type:
                            type: string
                            description: |-
                              WHAT KIND OF STRING `icon` IS — the discriminator, because the glyph cannot carry its own domain. `default` and `custom` mean `icon` is a Font Awesome CLASS; `emoji` means `icon` is a raw emoji GRAPHEME.
                              Read this before you render. A client that maps `icon` to an icon font unconditionally draws tofu for every emoji row, and one that prints it as text shows "fas fa-file-pdf" for the rest. No tenant holds an emoji row today, but Load Sample Data seeds three, so this is one button press from live rather than theoretical.
                              Nullable, because the column is: `library_items.icon_type` has no NOT NULL and the model's enum takes `allow_nil`, so an explicitly cleared row is a legal state. Treat null as `default`.
                            enum:
                            - default
                            - emoji
                            - custom
                            -
                            nullable: true
                            example: default
                          icon:
                            type: string
                            description: The glyph — a Font Awesome class or an emoji
                              grapheme, per `icon_type` above. Extension-specific
                              where known; overridden by the item's own icon when
                              an admin set one.
                            example: fas fa-file-pdf
                          icon_color:
                            type: string
                            description: Hex glyph colour.
                            example: "#c0392b"
                          icon_background:
                            type: string
                            description: Fill for the plate behind the glyph. Hex,
                              or an `rgba()` string when derived from an admin-chosen
                              item colour.
                            example: "#fbe7e5"
                          format:
                            type: string
                            nullable: true
                            description: Lowercase file extension without the dot,
                              or null when the item has nothing to infer one from
                              (a form, a wiki page, a bare link).
                            example: pdf
                          library:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 7
                              name:
                                type: string
                                example: Company Policies
                              icon:
                                type: string
                                example: fas fa-shield-halved
                              color:
                                type: string
                                example: "#3478f6"
                              enabled:
                                type: boolean
                                description: False only for a Libraries administrator
                                  looking at a disabled library's saved item — grey
                                  the row and badge it "Disabled".
                                example: true
                          category:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 21
                              name:
                                type: string
                                example: Leave & Time Off
                              position:
                                type: integer
                                example: 0
                          breadcrumb:
                            type: string
                            nullable: true
                            description: "`Library › Category`, pre-joined with the
                              separator the design uses."
                            example: Company Policies › Leave & Time Off
                          status:
                            type: string
                            description: "`available` — opens normally. `inactive`
                              — the linked record was unpublished/deactivated after
                              it was linked, OR this reader may not open it; show
                              the details panel, not a link. `unavailable` — the linked
                              record is gone; a library manager must relink or remove
                              it."
                            enum:
                            - available
                            - inactive
                            - unavailable
                            example: available
                          status_label:
                            type: string
                            nullable: true
                            description: The badge text the web renders beside the
                              row ("Inactive", "Source unavailable"), or null when
                              the item is healthy.
                            example:
                          url:
                            type: string
                            nullable: true
                            description: |-
                              Absolute destination — where a tap goes. NULL whenever `open_mode` is `unavailable`, including for an item this particular reader may not open even though a link exists for someone else.
                              ALSO NULL when `open_mode` is `form`: a form is opened BY ID, from `form_id`. Branch on `open_mode`, never on which of the two is set.
                          form_id:
                            type: integer
                            nullable: true
                            description: 'The id of the linked form — the locator
                              that replaced a form item''s web URL, for `open_mode:
                              form` only. Open your own form screen with it. Null
                              for every other type, and null for a form this reader
                              may not open (same rule `url` follows: `status` / `status_label`
                              say why).'
                            example:
                          copy_link_url:
                            type: string
                            nullable: true
                            description: 'What "Copy link" puts on the clipboard:
                              the absolute destination. The same value as `url` for
                              every type EXCEPT a form, where it is the card''s only
                              URL — the clipboard''s job is to produce something a
                              person can paste, and a form id pastes into nothing.
                              Null whenever `open_mode` is `unavailable`.'
                          link_url:
                            type: string
                            nullable: true
                            description: The source URL an admin stored on the item,
                              which the details panel prints on its own row. Distinct
                              from `url`, which is where a tap goes.
                          opens_in:
                            type: string
                            nullable: true
                            description: |-
                              The item's configured link target — what the web anchor's `target` reads. `GET /libraries/{id}` carries the same column as `open.target`.
                              NULLABLE, and a typed client must model it as optional. `library_items.link_target` is `t.string default: "new_tab"` with no NOT NULL, the model's enum takes `allow_nil`, and the item form deliberately submits an empty value — which casts back to nil — for every item type whose "Open link in" panel is never shown, so an unanswered question stays unanswered rather than being answered "New tab" on the admin's behalf. No row holds null today; the first one would crash a client with a non-optional String here.
                            enum:
                            - new_tab
                            - current_tab
                            -
                            example: new_tab
                          open_mode:
                            type: string
                            description: How to open it, and which of `url` / `form_id`
                              carries the locator. `form` — a form this app hosts;
                              route to your own form screen using `form_id` (`url`
                              is null). `external` — an off-platform http(s) URL;
                              hand it to the system browser. `preview` — a file this
                              app serves; open the file preview or the media viewer.
                              `in_app` — a path inside the platform (a wiki page,
                              a survey, a document record). `unavailable` — nothing
                              to open; show the details panel and `status_label` says
                              why.
                            enum:
                            - form
                            - external
                            - preview
                            - in_app
                            - unavailable
                            example: preview
                          download_url:
                            type: string
                            nullable: true
                            description: Absolute URL for the same blob served as
                              an attachment, or null when the item has no file to
                              save (a link, a form, a wiki page, or a document record
                              that stores no blob of its own). Omit the Download row
                              from the sheet when null rather than showing it inert.
                          media:
                            type: object
                            nullable: true
                            description: The item's own uploaded file. Null when it
                              has none.
                            properties:
                              filename:
                                type: string
                                example: remote-work.pdf
                              content_type:
                                type: string
                                example: application/pdf
                              byte_size:
                                type: integer
                                example: 1153434
                              size_label:
                                type: string
                                description: Pre-formatted, matching what the details
                                  panel prints.
                                example: 1.1 MB
                              width:
                                type: integer
                                nullable: true
                                description: |-
                                  Pixel width, ALWAYS an integer, and null — never zero — for a blob Active Storage has not analyzed yet.
                                  Integer is a coercion, not a passthrough: the two analyzers disagree on type. The image analyzer writes vips' Integer while the VIDEO analyzer writes `Float(video_stream["width"])` by design, and this key used to ship whichever the row happened to hold — 2,000 image blobs as Integer, 149 video blobs as Float, under one key. Since 2026-09-05 both are coerced, which is safe because a pixel count is a whole number in either analyzer.
                                example: 2400
                              height:
                                type: integer
                                nullable: true
                                description: Pixel height. Integer on the same terms
                                  as `width`.
                                example: 1600
                              dimensions:
                                type: string
                                nullable: true
                                description: '`width × height`, or null when either
                                  is unknown. Integral on both sides — it read "1920.0
                                  × 1080.0" for every video until the coercion above
                                  landed.'
                                example: 2400 × 1600
                              duration_seconds:
                                type: number
                                nullable: true
                                description: Video length. Null until background analysis
                                  has run on a freshly uploaded file — that is a real
                                  "not yet known", not a zero.
                                example: 92.4
                              duration_label:
                                type: string
                                nullable: true
                                description: "`m:ss` or `h:mm:ss`."
                                example: '1:32'
                              url:
                                type: string
                                description: Absolute URL for the blob served INLINE
                                  (preview / viewer).
                          created_by:
                            type: object
                            nullable: true
                            description: 'Who added the item — the details panel''s
                              "Added by" byline. NAMED `created_by`, NOT `added_by`,
                              since 2026-09-05: those are the names the columns actually
                              have, and the names every other payload in this namespace
                              uses (`GET /libraries/{id}` for both the item and the
                              library). `added_by` / `added_at` were the only two
                              occurrences of that spelling in the whole `/api/v1`
                              surface — 2 sites against 254 — so a client decoding
                              "who added this, and when" needed two field names depending
                              on the endpoint.'
                            properties:
                              id:
                                type: integer
                                example: 88
                              name:
                                type: string
                                example: Neha Kulkarni
                          created_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the ITEM was added to the library. Not
                              to be confused with `bookmark.created_at`, which is
                              when THIS CALLER saved it — the key this list is ordered
                              by.
                          updated_by:
                            type: object
                            nullable: true
                            description: Null until somebody edits the item.
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                          updated_at:
                            type: string
                            format: date-time
                            nullable: true
                          can_manage:
                            type: boolean
                            description: May this caller edit / move / delete items
                              in this library. Gates the 3-dot sheet's Edit, Move
                              and Delete rows; it follows the library's management
                              level, not the caller's role alone.
                            example: false
                          bookmarked:
                            type: boolean
                            description: Always true on this endpoint. Present so
                              one card type serves every list.
                            example: true
                          bookmark:
                            type: object
                            nullable: true
                            description: The caller's own bookmark row.
                            properties:
                              id:
                                type: integer
                                description: The BOOKMARK id (not the item id). Useful
                                  for local ordering; the bookmark write endpoints
                                  address the ITEM, not this.
                                example: 9021
                              note:
                                type: string
                                nullable: true
                                description: The caller's own note, authored on the
                                  web /bookmarks page.
                                example: Read before the audit
                              created_at:
                                type: string
                                format: date-time
                                description: When it was saved — the sort key of this
                                  list.
                      - type: object
                        properties:
                          search_score:
                            type: integer
                            description: The score this row was ranked by.
                            example: 4
                  meta:
                    "$ref": "#/components/schemas/LibrarySearchItemPagination"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    type: object
                    description: Standard response metadata
                    additionalProperties: true
                    properties:
                      request_id:
                        type: string
                        description: Unique request identifier
                      generated_at:
                        type: string
                        format: date-time
                        description: Timestamp when the response was generated
                      execution_time_ms:
                        type: number
                        description: Server processing time in milliseconds
                      total_count:
                        type: integer
                        description: Total number of items
                      total_pages:
                        type: integer
                        description: Total number of pages
                      current_page:
                        type: integer
                        description: Current page number
                      per_page:
                        type: integer
                        description: Number of items per page
        '400':
          description: "`q` was missing, blank or whitespace-only (`invalid_request`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token lacks `read:libraries` (`insufficient_permissions`),
            or the Libraries app is not accessible to this caller — not enabled for
            the tenant, or the caller is outside the app's audience (`access_denied`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/libraries/{id}/disable":
    parameters:
    - name: id
      in: path
      required: true
      description: The LIBRARY id (not a category, and not an item).
      schema:
        type: integer
    post:
      tags:
      - Libraries
      summary: Disable a library
      description: |
        Takes the library **out of circulation** — the native mirror of the web
        **Disable** control (the All Libraries row menu and the library header,
        both of which reach
        `Apps::Libraries::SpacesController#toggle_enabled`). Both surfaces perform
        the transition through the shared `Libraries::EnablementService`, so
        neither can drift on what disabling means, who may do it, or what the
        confirmation says.

        A disabled library **drops off every browse, search and mobile surface**.
        It stays visible — badged `enabled: false` — only to callers who may
        administer Libraries, because the index is the surface that offers the way
        back. Its categories, items and audience rules are **untouched**: this is
        a reversible hide, not a delete.

        ### Idempotent, deliberately NOT a toggle

        This is the one place the endpoint diverges from the web control it
        mirrors. A button can safely mean "flip it"; a network client retries, and
        a second toggle would silently **re-enable** a library the caller had just
        taken down.

        So disabling an already-disabled library is a no-op that still answers
        `200`: it writes nothing, leaves `updated_at` untouched, and reports
        `changed: false`. Ten retries are indistinguishable from one call. Read
        `changed` to know whether this call was the one that moved the flag.

        The way back is the sibling verb, `POST /libraries/{id}/enable`. The web
        renders both as ONE control whose label flips; this API splits it into
        two verbs precisely so a retry cannot undo itself.

        ### Authorization — three gates

        1. **Token scope `write:libraries`.** Unlike the read surface in this
           file, the write is scope-gated. The scope IS grantable: it reaches
           `ApiToken::AVAILABLE_SCOPES` through `Mcp::ScopeRegistry`, which mints
           `read:` / `write:` / `destructive:` for every `Agents::ToolRegistry`
           domain, so an admin-issued token can hold it and the mobile login paths
           already mint it. `has_scope?` fails **closed** on a scopeless token, and
           the blanket `admin` scope satisfies it. A session-authenticated caller
           (native WebView, internal call) carries no token and passes, exactly as
           `require_scope` defines it.
        2. **Libraries app access** — enablement (explicit row OR pricing tier OR
           active subscription) AND the app's per-group visibility. Otherwise
           `403 access_denied`.
        3. **Libraries administrator** — `Libraries::Access#admin?`, precisely
           what the web's `authorize_admin_access` asks, and precisely what the
           index's `can_disable` flag reports. Business admins, super admins and
           **per-app Libraries admins** all qualify; a manager with no Libraries
           grant does not. Otherwise `403 forbidden`.

        Gate 3 is **app-wide, not per-library**, and is answered BEFORE the
        library is resolved. So an unauthorized caller gets `403` for a real id
        and for a nonexistent one alike, and cannot enumerate library ids by
        reading `403`-vs-`404`. A library belonging to another tenant is `404`,
        never `403` — a `403` would confirm the id is real somewhere.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The library is disabled. Returned whether this call changed
            it or it was already off — read `changed` to tell those apart.
          content:
            application/json:
              schema:
                type: object
                required:
                - library
                - changed
                - message
                properties:
                  library:
                    allOf:
                    - "$ref": "#/components/schemas/LibraryCard"
                    description: The library AFTER the call, in the same card shape
                      `GET /libraries/list` emits — counts included — so a client
                      redraws the row from this one response with no follow-up. `enabled`
                      is always `false` here.
                  changed:
                    type: boolean
                    description: "`true` when this call moved the flag; `false` when
                      the library was already disabled and nothing was written."
                    example: true
                  message:
                    type: string
                    description: Confirmation copy, byte-identical to the web flash
                      — it describes what is TRUE now, so it reads the same on a retry.
                    enum:
                    - Library disabled successfully.
                    example: Library disabled successfully.
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token (`authentication_required`).
        '403':
          description: The token lacks `write:libraries` (`insufficient_permissions`);
            the Libraries app is not enabled for the tenant or the caller is outside
            its audience (`access_denied`); or the caller is not a Libraries administrator
            (`forbidden`). Nothing is written in any of the three.
        '404':
          description: No library with that id exists in the caller's tenant (`not_found`).
            Another tenant's library answers this too — never `403`.
        '422':
          description: The library could not be saved (`invalid`). The `message` names
            the validation that refused it.
  "/libraries/{id}/enable":
    parameters:
    - name: id
      in: path
      required: true
      description: The LIBRARY id (not a category, and not an item).
      schema:
        type: integer
    post:
      tags:
      - Libraries
      summary: Enable a library
      description: |
        Puts a disabled library **back into circulation** — the way back from
        `POST /libraries/{id}/disable`, and the native mirror of the web
        **Enable** control.

        On the web, Enable and Disable are the SAME control: the All Libraries
        row menu and the library header render one entry whose label flips with
        the current state, and both reach
        `Apps::Libraries::SpacesController#toggle_enabled`. Every surface performs
        the transition through the shared `Libraries::EnablementService`, so none
        can drift on what enabling means, who may do it, or what the confirmation
        says.

        Enabling restores the library to **every browse, search and mobile
        surface**. Nothing else about it changes — disabling never touched its
        categories, items or audience rules, so there is nothing to restore
        beyond the flag.

        ### Idempotent, deliberately NOT a toggle

        A button can safely mean "flip it" because a person sees the result; a
        network client retries, and a retried toggle undoes itself. So this API
        splits the web's single control into two verbs that each name the state
        they move **to**.

        Enabling an already-enabled library is a no-op that still answers `200`:
        it writes nothing, leaves `updated_at` untouched, and reports
        `changed: false`. Ten retries are indistinguishable from one call. Read
        `changed` to know whether this call was the one that moved the flag.

        ### Authorization — three gates

        Identical to `POST /libraries/{id}/disable`, and deliberately so: ONE
        capability governs both directions, which means a caller who may take a
        library down may always put it back.

        1. **Token scope `write:libraries`.** The scope IS grantable — it reaches
           `ApiToken::AVAILABLE_SCOPES` through `Mcp::ScopeRegistry` and the
           mobile login paths already mint it. `has_scope?` fails **closed** on a
           scopeless token, and the blanket `admin` scope satisfies it. A
           session-authenticated caller (native WebView, internal call) carries no
           token and passes, exactly as `require_scope` defines it.
        2. **Libraries app access** — enablement (explicit row OR pricing tier OR
           active subscription) AND the app's per-group visibility. Otherwise
           `403 access_denied`.
        3. **Libraries administrator** — `Libraries::Access#admin?`, precisely
           what the web's `authorize_admin_access` asks, and precisely what the
           index's `can_disable` flag reports. Business admins, super admins and
           **per-app Libraries admins** all qualify; a manager with no Libraries
           grant does not. Otherwise `403 forbidden`.

        Gate 3 is **app-wide, not per-library**, and is answered BEFORE the
        library is resolved. So an unauthorized caller gets `403` for a real id
        and for a nonexistent one alike, and cannot enumerate library ids by
        reading `403`-vs-`404`. A library belonging to another tenant is `404`,
        never `403` — a `403` would confirm the id is real somewhere.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The library is enabled. Returned whether this call changed
            it or it was already on — read `changed` to tell those apart.
          content:
            application/json:
              schema:
                type: object
                required:
                - library
                - changed
                - message
                properties:
                  library:
                    allOf:
                    - "$ref": "#/components/schemas/LibraryCard"
                    description: The library AFTER the call, in the same card shape
                      `GET /libraries/list` emits — counts included — so a client
                      redraws the row from this one response with no follow-up. `enabled`
                      is always `true` here.
                  changed:
                    type: boolean
                    description: "`true` when this call moved the flag; `false` when
                      the library was already enabled and nothing was written."
                    example: true
                  message:
                    type: string
                    description: Confirmation copy, byte-identical to the web flash
                      — it describes what is TRUE now, so it reads the same on a retry.
                    enum:
                    - Library enabled successfully.
                    example: Library enabled successfully.
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token (`authentication_required`).
        '403':
          description: The token lacks `write:libraries` (`insufficient_permissions`);
            the Libraries app is not enabled for the tenant or the caller is outside
            its audience (`access_denied`); or the caller is not a Libraries administrator
            (`forbidden`). Nothing is written in any of the three.
        '404':
          description: No library with that id exists in the caller's tenant (`not_found`).
            Another tenant's library answers this too — never `403`.
        '422':
          description: The library could not be saved (`invalid`). The `message` names
            the validation that refused it.
  "/libraries/items/{id}/bookmark":
    parameters:
    - name: id
      in: path
      required: true
      description: The library ITEM id (not the library, and not the category).
      schema:
        type: integer
    post:
      tags:
      - Libraries
      summary: Bookmark a library item for the current user
      description: |
        Saves the item for the **calling user**, writing the platform-wide
        `Platform::Bookmark` shared with the web item kebab's **Bookmark this**
        entry, the `/bookmarks` page and My Stuff. An item saved on the phone is
        saved on the web, and vice versa — there is no app-local "library item
        pin".

        Bookmarks in Libraries are **items-only**: never a library, never a
        category. That is why the path carries an explicit `items/` segment
        rather than hanging off a library id.

        **Idempotent** — bookmarking an already-bookmarked item is a no-op that
        still returns `bookmarked: true` and never creates a second row. This is
        the deliberate divergence from the web control, which is a single
        *toggle* because one button serves both directions: a retried `POST`
        here must never silently un-bookmark. Read the saved set back with
        `GET /libraries/bookmarks`.

        * **Authorization — anyone who can OPEN the item may bookmark it.**
          Requires the `write:libraries` scope (the floor every write in this
          namespace applies) and Libraries-app access. The item is then resolved
          inside the caller's visible scope: visibility is inherited from the
          parent library's audience, so a restricted library, a disabled one, a
          cross-tenant id and a missing id all return `404` alike — none of them
          reveals that the item exists.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Item bookmarked (or already bookmarked).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LibraryItemBookmarkState"
        '401':
          description: Missing or invalid API token
        '403':
          description: Missing the `write:libraries` scope (`insufficient_permissions`),
            or the Libraries app is not accessible to the caller (`access_denied`).
        '404':
          description: No library item with that id is visible to the caller (`not_found`).
    delete:
      tags:
      - Libraries
      summary: Remove the current user's bookmark on a library item
      description: |
        Removes the calling user's bookmark on the item. **Idempotent** —
        removing a bookmark that isn't there is a no-op that still returns
        `bookmarked: false`.

        A bookmark carrying a note is soft-deleted (recoverable from the
        platform Trash); a bare one is removed outright. That asymmetry is the
        web toggle's, and it exists so routine un-saves don't bury the things
        Trash is for.

        * **Authorization is deliberately ASYMMETRIC with the POST verb.** An
          **already-saved** bookmark can always be cleared — even once the item
          has gone out of reach, because its library was disabled or its
          audience narrowed. `GET /libraries/bookmarks` drops such rows from the
          list (listing them would leak the title of content the caller cannot
          open), so this verb is the only way they can be cleared and must not
          refuse them. Removing something that is **neither bookmarked nor
          visible** still returns `404`, so the verb never becomes a way to
          probe which item ids exist. Scope and app-access gates are unchanged.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Bookmark removed (or there was none).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LibraryItemBookmarkState"
        '401':
          description: Missing or invalid API token
        '403':
          description: Missing the `write:libraries` scope (`insufficient_permissions`),
            or the Libraries app is not accessible to the caller (`access_denied`).
        '404':
          description: The caller has no bookmark on that item AND no library item
            with that id is visible to them (`not_found`).
  "/libraries/bookmarks":
    get:
      tags:
      - Libraries
      summary: The caller's saved (bookmarked) library items
      description: |
        The library items the calling user has bookmarked — most recently saved
        first — plus the per-library groups needed to render them grouped.

        Returns `200` with an empty `items` array (never an error) when the
        caller has saved nothing.

        See the file header for ordering, the exclusion rules, the authorization
        model, and the fields that are deliberately absent.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        description: 1-based page number. A missing, zero, negative or non-numeric
          value is treated as page 1. A client that array-wraps its query params (`?page[]=2`)
          is honoured as the scalar rather than refused.
        schema:
          type: integer
          minimum: 1
          default: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page, clamped to 1..50. A value that does not parse
          as a positive integer is treated as ABSENT and falls back to 20 — it is
          NOT clamped up to 1.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        example: 20
      responses:
        '200':
          description: Saved items retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - libraries
                - groups_total
                - meta
                properties:
                  items:
                    type: array
                    description: This page of saved items, most recently saved first.
                    items:
                      "$ref": "#/components/schemas/LibraryBookmarkedItem"
                  libraries:
                    type: array
                    description: |-
                      One row per library the caller has saved something in, with the count of their saved items in it. Counted over the whole saved set (not the page), ordered by count descending then name, and containing no library the caller cannot see.
                      **PAGE 1 ONLY.** This array does not paginate — it describes the whole saved set — so it is sent once and every later page answers `[]`. An empty array is therefore not a statement about how much the caller has saved: read `groups_total`, which is present on every page. See the file header for the measurement behind this.
                    items:
                      "$ref": "#/components/schemas/LibraryBookmarkGroup"
                  groups_total:
                    type: integer
                    description: |-
                      How many library groups the caller's saved set spans — present and complete on EVERY page, including the pages where `libraries` is `[]`.
                      This is the field that makes the page-1-only contract readable: `libraries: []` with `groups_total > 0` means "already sent on page 1", and with `groups_total == 0` means "nothing saved". A client that instead took `libraries.length` as the group count would report zero groups for every page after the first.
                    example: 2
                  meta:
                    "$ref": "#/components/schemas/LibraryBookmarkPagination"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
              examples:
                saved:
                  summary: Two saved items across two libraries — and the two shapes
                    of `icon`
                  description: 'The first row is `icon_type: default`, so `icon` is
                    a Font Awesome class. The second is `icon_type: emoji`, so `icon`
                    is a raw emoji GRAPHEME — pass it to an icon font and the row
                    renders tofu. Switch on `icon_type`, never on the string''s shape.'
                  value:
                    items:
                    - id: 412
                      title: Remote Work Policy
                      description: How and when you can work remotely
                      item_type: file
                      item_type_label: PDF
                      icon_type: default
                      icon: fas fa-file-pdf
                      icon_color: "#c0392b"
                      icon_background: "#fbe7e5"
                      format: pdf
                      library:
                        id: 7
                        name: Company Policies
                        icon: fas fa-shield-halved
                        color: "#3478f6"
                        enabled: true
                      category:
                        id: 21
                        name: Leave & Time Off
                        position: 0
                      breadcrumb: Company Policies › Leave & Time Off
                      status: available
                      status_label:
                      url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf
                      form_id:
                      copy_link_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf
                      link_url:
                      opens_in: new_tab
                      open_mode: preview
                      download_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf?disposition=attachment
                      media:
                        filename: remote-work.pdf
                        content_type: application/pdf
                        byte_size: 1153434
                        size_label: 1.1 MB
                        width:
                        height:
                        dimensions:
                        duration_seconds:
                        duration_label:
                        url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf
                      created_by:
                        id: 88
                        name: Neha Kulkarni
                      created_at: '2026-03-12T09:14:00Z'
                      updated_by:
                      updated_at: '2026-08-30T11:02:00Z'
                      can_manage: false
                      bookmarked: true
                      bookmark:
                        id: 9021
                        note: Read before the audit
                        created_at: '2026-08-31T16:40:12Z'
                    - id: 512
                      title: Corporate card portal
                      description: Statements, limits and dispute forms
                      item_type: simple_link
                      item_type_label: Link
                      icon_type: emoji
                      icon: "\U0001F4B3"
                      icon_color: "#11936f"
                      icon_background: "#d4efe7"
                      format:
                      library:
                        id: 9
                        name: Brand Assets
                        icon: fas fa-palette
                        color: "#8b5cf6"
                        enabled: true
                      category:
                        id: 33
                        name: Finance
                        position: 2
                      breadcrumb: Brand Assets › Finance
                      status: available
                      status_label:
                      url: https://intranet.acme.com/cards
                      form_id:
                      copy_link_url: https://intranet.acme.com/cards
                      link_url: https://intranet.acme.com/cards
                      opens_in: new_tab
                      open_mode: external
                      download_url:
                      media:
                      created_by:
                        id: 91
                        name: Rajveer Sandhu
                      created_at: '2026-07-27T10:00:00Z'
                      updated_by:
                      updated_at: '2026-08-18T10:00:00Z'
                      can_manage: true
                      bookmarked: true
                      bookmark:
                        id: 9020
                        note:
                        created_at: '2026-08-29T08:11:04Z'
                    libraries:
                    - id: 7
                      title: Company Policies
                      icon: fas fa-shield-halved
                      color: "#3478f6"
                      library_type: mixed_content
                      enabled: true
                      bookmarked_count: 1
                    - id: 9
                      title: Brand Assets
                      icon: fas fa-palette
                      color: "#8b5cf6"
                      library_type: mixed_content
                      enabled: true
                      bookmarked_count: 1
                    groups_total: 2
                    meta:
                      total_count: 2
                      total_pages: 1
                      current_page: 1
                      per_page: 20
                      has_next_page: false
                      has_prev_page: false
                saved_form:
                  summary: A saved FORM item — opened by id, not by URL
                  description: "`open_mode: form` and `url: null`; the locator is
                    `form_id`. `copy_link_url` is still a real URL, because that is
                    what the clipboard needs."
                  value:
                    items:
                    - id: 618
                      title: Expense Claim
                      description: Submit receipts for reimbursement
                      item_type: form
                      item_type_label: Form
                      icon_type: default
                      icon: fas fa-clipboard-list
                      icon_color: "#9a5b06"
                      icon_background: "#fbeecd"
                      format:
                      library:
                        id: 7
                        name: Company Policies
                        icon: fas fa-shield-halved
                        color: "#3478f6"
                        enabled: true
                      category:
                        id: 24
                        name: Finance
                        position: 1
                      breadcrumb: Company Policies › Finance
                      status: available
                      status_label:
                      url:
                      form_id: 583
                      copy_link_url: https://acme.workforce.mangoapps.com/apps/forms/templates/583
                      link_url:
                      opens_in: new_tab
                      open_mode: form
                      download_url:
                      media:
                      created_by:
                        id: 88
                        name: Neha Kulkarni
                      created_at: '2026-05-04T08:30:00Z'
                      updated_by:
                      updated_at: '2026-05-04T08:30:00Z'
                      can_manage: false
                      bookmarked: true
                      bookmark:
                        id: 9033
                        note:
                        created_at: '2026-09-01T12:05:00Z'
                    libraries:
                    - id: 7
                      title: Company Policies
                      icon: fas fa-shield-halved
                      color: "#3478f6"
                      library_type: mixed_content
                      enabled: true
                      bookmarked_count: 1
                    groups_total: 1
                    meta:
                      total_count: 1
                      total_pages: 1
                      current_page: 1
                      per_page: 20
                      has_next_page: false
                      has_prev_page: false
                empty:
                  summary: Nothing saved yet
                  value:
                    items: []
                    libraries: []
                    groups_total: 0
                    meta:
                      total_count: 0
                      total_pages: 0
                      current_page: 1
                      per_page: 20
                      has_next_page: false
                      has_prev_page: false
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:libraries`;
            or `access_denied` — the Libraries app is not accessible to this caller."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - insufficient_permissions
                        - access_denied
                      message:
                        type: string
              examples:
                no_scope:
                  value:
                    error:
                      code: insufficient_permissions
                      message: 'Required scope: read:libraries'
                no_app:
                  value:
                    error:
                      code: access_denied
                      message: You do not have access to the Libraries app
  "/libraries/{id}":
    get:
      tags:
      - Libraries
      summary: Library detail — categories, items, view and ordering
      description: |
        One library with its categories and their items.

        **The header** (`library`) is the same card `/libraries/list` returns —
        same field names, same fallbacks — plus the settings and capability
        flags only the detail screen needs: `default_view_mode`, the two icon
        switches, and `can_manage_items` / `can_manage_library` /
        `can_reorder_categories`. A client can therefore draw the header from
        the row it already holds and fill in the rest.

        **Three header fields are manager-only and are ABSENT, not null, for
        everyone else**: `visibility`, `management_level` and `created_by`, all
        gated on `can_manage_library`. None of the three is in
        `LibraryDetailHeader.required` — read the flag, or check for the key,
        before binding a control to any of them. Each carries the full reason on
        its own entry below.

        **Each category** carries the `view` it renders in and the `sort_order`
        it sorts by — both admin-owned — plus `items_sorted_by` (the rule that
        actually applied, which differs from `sort_order` whenever `?sort=`
        overrides it) and `can_reorder_items`.

        **Each item** carries everything the row, the actions sheet and the
        details drawer read: the type and format labels, the tinted mark, the
        thumbnail, where it sits, its source state, both bylines, the file's
        format / size / dimensions / duration, the resolved destination with its
        `mode` and per-type description, the absolute copy link, the download,
        the caller's bookmark state, and `actions` — the kebab's option set,
        already gated, so a client renders the menu without re-implementing a
        single permission rule.

        **The management options in that menu are web hand-offs, not API
        verbs.** This namespace is a read API with two writes (the bookmark
        toggle and item delete) — there is no `PATCH`/`PUT`, no item-create and
        no reorder anywhere in `/api/v1/libraries`. So `actions.edit` and
        `actions.move` ship the item's `manage_url`, `can_reorder_items` ships
        the category's, and `can_reorder_categories` uses the library's `link`:
        each flag says whether to draw the row, and the URL says where the tap
        goes. Read `LibraryItemActions` before wiring the menu.

        **Empty categories are kept**, with `items: []`, so the response never
        hides the library's structure.

        ### Fields deliberately NOT carried

        The design mockup's item drawer shows a review state (`review`,
        `reviewEvery`), an expiry date, a version stack, comment and reaction
        counts, and a page count. `library_items` has no column behind any of
        them, so they are absent rather than fabricated — a made-up value on a
        governance surface is worse than a missing one. For the same reason the
        mockup's fourth sort, "Most viewed", is not accepted: nothing records a
        per-item view count. Passing `sort=views` falls back to `default`, and
        the echoed `sort` says so.
      parameters:
      - name: id
        in: path
        required: true
        description: Library id. Digit-constrained by the route.
        schema:
          type: integer
      - name: sort
        in: query
        required: false
        description: The reader's temporary page-wide order, applied to the category
          BLOCKS as well as to the items inside them. `default` (the default) means
          each category keeps its OWN `sort_order`; `az` and `recent` override every
          category. `updated` is accepted as an alias of `recent` for a client written
          against the mobile prototype, and the response always echoes the CANONICAL
          key. Any other value — including the prototype's unsupported `views` — falls
          back to `default`, exactly as the web dropdown does.
        schema:
          type: string
          enum:
          - default
          - az
          - recent
          - updated
          default: default
      - name: category_id
        in: query
        required: false
        description: |-
          Scope the response to ONE category — how a notification or a search hit deep-links into a single section. `meta.scoped_to_category_id` echoes it, and `meta.categories_count` / `meta.items_count` narrow with it so they always describe what was returned.
          `library.categories_count` / `library.items_count` do NOT narrow: they are the library's TRUE totals, identical to what `/libraries/list` returned for the same library. See those two fields for why the pair is split this way.
          An id that belongs to a DIFFERENT library (or to another tenant) returns an empty `categories` array rather than that library's items, and never 404s the library the caller legitimately asked for. A non-numeric value is ignored.
        schema:
          type: integer
      responses:
        '200':
          description: The library, its categories and their items
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LibraryDetail"
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:libraries`;
            or `access_denied` — the Libraries app is not accessible to this caller,
            either because the tenant is not entitled to it or because the caller
            is outside the app's per-group visibility rules."
        '404':
          description: No library this caller may access has that id. Returned identically
            for a library restricted to another audience, a disabled library the caller
            cannot manage, another tenant's library, and an id that does not exist
            — so the response cannot be used to prove a library exists. Error code
            `not_found`.
  "/libraries/{library_id}/items/{id}":
    parameters:
    - name: library_id
      in: path
      required: true
      description: The library the item belongs to. The item is looked up THROUGH
        it, so this is not redundant — see the 404 notes on the operation.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The library ITEM id (not the library, and not the category).
      schema:
        type: integer
    delete:
      tags:
      - Libraries
      summary: Delete a library item
      description: |
        Permanently removes one item from a library — the native mirror of the
        web item kebab's ⋯ ▸ **Delete**
        (`Apps::Libraries::ItemsController#destroy`).

        **Authorization — whoever the web shows the Delete entry to, and nobody
        else.** Both surfaces ask the same predicate
        (`Libraries::Access#can_manage_items?`), so the API cannot admit someone
        the button hides from. That predicate is:

        | Library `management_level` | Who may delete an item |
        |---|---|
        | *(any)* | A Libraries **app admin** — which includes every business admin and super admin — always may. This short-circuit is evaluated **before** the level. |
        | `anyone` | Anyone who can **view** the library (all-users libraries: every member; specific-audience libraries: whoever an audience rule matches, at any role). |
        | `admins_and_specific` | A member matched by an audience rule whose role is **contributor** or **manager**. A `viewer`-role rule is not enough. |
        | `admins_only` | Admins only — the audience roster is ignored entirely, so a `manager`-role rule does **not** grant it. |
        | `domain_admins_only` | Super admins (plus the admin short-circuit above). |

        Audience rules match on any populated target — user, department, group,
        location, job family, job title, organizational role, or platform role —
        so a rule naming a *role* grants the same right as one naming a person.

        **What the delete removes** (the model owns the cascade, so this is
        byte-for-byte what the web's own `@item.destroy` leaves behind):

        * the library item row,
        * its uploaded file, if it had one (the ActiveStorage blob is purged),
        * its content-governance findings,
        * **every user's bookmark on it** — hard-deleted, not trashed. Those
          rows are user-visible on `/bookmarks` and count toward the saved-item
          total, so an orphan would inflate somebody's count forever.
          `bookmarks_deleted` reports how many went.

        The **library, the category and every sibling item are untouched** — an
        item is a leaf.

        **Not idempotent.** A second call returns `404 not_found`, because the
        item is genuinely gone. Clients should treat `404` on a retry as success.

        **Why the library id is in the path.** The item is resolved *through*
        the named library, exactly as the web's nested route does. Pairing a
        library you manage with an item id from one you do not returns `404`,
        never a delete. For the same reason an unknown library, a library in
        another tenant, and an item in another library are all `404` alike —
        none of them reveals that the id exists.

        **Order of checks.** The permission gate runs **before** the item
        lookup, so an unauthorized caller receives `403` whether or not the item
        exists and cannot probe for valid ids by reading `403` against `404`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Item deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_id
                - deleted
                - message
                properties:
                  item_id:
                    type: integer
                    description: The id of the item that was deleted.
                    example: 4821
                  title:
                    type: string
                    description: Its title, as it read at the moment of deletion.
                    example: Fire drill procedure
                  item_type:
                    type: string
                    description: The kind of item it was.
                    enum:
                    - simple_link
                    - file
                    - form
                    - survey
                    - wiki
                    - post
                    - image
                    - video
                    example: simple_link
                  library_id:
                    type: integer
                    description: The library it was removed from.
                    example: 12
                  library_name:
                    type: string
                    example: Employee Handbook
                  category_id:
                    type: integer
                    nullable: true
                    description: The category it sat in. Still present — the category
                      is not deleted with it.
                    example: 34
                  category_name:
                    type: string
                    nullable: true
                    example: Safety
                  bookmarks_deleted:
                    type: integer
                    description: How many users' bookmarks on this item were removed
                      by the cascade. Includes bookmarks already in the platform Trash.
                    example: 2
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  message:
                    type: string
                    example: Item removed from library.
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token (`authentication_required` / `invalid_token`).
        '403':
          description: Missing the `write:libraries` scope (`insufficient_permissions`);
            the Libraries app is not accessible to the caller (`access_denied`); or
            the caller may not manage items in this library (`forbidden` — "You do
            not have permission to manage items in this library").
        '404':
          description: No such library in this business, no such item in that library,
            or the item is already gone (`not_found`). Cross-library and cross-tenant
            ids answer here, never 403.
        '422':
          description: The delete was refused by the record itself (`delete_failed`).
            The message carries the model's own reason.
  "/wikis/dashboard":
    get:
      tags:
      - Wikis
      summary: Wikis dashboard
      description: |
        The native-client mirror of the web Wikis dashboard. Every number and
        list is produced by the same query object that backs the web page, so
        the two surfaces cannot drift.

        **The response is persona-aware.** `is_admin` tells the client which
        shape it received:

        * **Every viewer** gets the `my_wikis`, `bookmarked`, `mentions` and
          `total_views` KPIs plus `my_drafts`, `my_bookmarked_wikis` and
          `recently_updated`. A DEPRECATED `stats.pinned` KPI and a DEPRECATED
          `my_pinned_wikis` list mirror `stats.bookmarked` and
          `my_bookmarked_wikis` exactly, for native builds shipped before the
          pin→bookmark rename. Read the `bookmarked` keys; ignore the `pinned`
          ones. `total_views.this_month` is scoped per persona —
          for a non-admin it counts views **this month on the pages that viewer
          created** — and a non-admin payload additionally carries
          `total_views.total` (**all-time** views on those same pages, the
          figure the web dashboard's "Total Views" card renders) and
          `recently_viewed`.
        * **Admin-tier viewers** (business admin or above) additionally get the
          `total_wikis` (including archived) and `stale_wikis` KPIs, a
          `total_views.this_month` figure, the configured `stale_days` window,
          and the `most_viewed_wikis`, `top_contributors`, `recent_activity`
          and `needs_attention` sections. Admin-only keys are **absent** (not
          null) for non-admins.

        Counts and lists are visibility-scoped: a non-admin never sees drafts
        or group-restricted wikis they could not open. Every list returns at
        most 5 rows.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    required:
                    - is_admin
                    - stats
                    - my_drafts
                    - my_bookmarked_wikis
                    - recently_updated
                    properties:
                      is_admin:
                        type: boolean
                        description: True when the caller is a business admin or above.
                          Determines whether the admin-only keys below are present.
                        example: true
                      stale_days:
                        type: integer
                        description: Admin only. The configured stale window in days
                          — a published wiki not updated within it counts toward `stale_wikis`.
                        example: 120
                      stats:
                        type: object
                        properties:
                          my_wikis:
                            type: object
                            description: Pages the caller authored.
                            properties:
                              total:
                                type: integer
                                example: 12
                              published:
                                type: integer
                                example: 9
                              drafts:
                                type: integer
                                example: 3
                          bookmarked:
                            type: object
                            description: Wikis the caller bookmarked.
                            properties:
                              total:
                                type: integer
                                example: 4
                          pinned:
                            type: object
                            deprecated: true
                            description: DEPRECATED mirror of `bookmarked` for pre-rename
                              native builds. Always equal to it.
                            properties:
                              total:
                                type: integer
                                example: 4
                          mentions:
                            type: object
                            description: Wikis whose comments @mention the caller.
                            properties:
                              total:
                                type: integer
                                example: 2
                          total_views:
                            type: object
                            description: 'Page views. The SCOPE of `this_month` is
                              persona-dependent: org-wide for admin-tier viewers,
                              and limited to the pages the caller CREATED for everyone
                              else. Read `is_admin` to know which you received. `total`
                              is the ALL-TIME view sum on the pages the caller created
                              and is present for non-admins only — there is no org-wide
                              equivalent behind the admin reading.'
                            properties:
                              this_month:
                                type: integer
                                example: 348
                              total:
                                type: integer
                                example: 1290
                          total_wikis:
                            type: object
                            description: Admin only. Org-wide totals by status.
                            properties:
                              total:
                                type: integer
                                example: 86
                              published:
                                type: integer
                                example: 70
                              drafts:
                                type: integer
                                example: 11
                              archived:
                                type: integer
                                example: 5
                          stale_wikis:
                            type: object
                            description: Admin only. Published wikis older than `stale_days`.
                            properties:
                              total:
                                type: integer
                                example: 6
                      my_drafts:
                        type: array
                        description: The caller's unpublished drafts, most recently
                          edited first (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              last_edited_at:
                                type: string
                                format: date-time
                      my_bookmarked_wikis:
                        type: array
                        description: The caller's bookmarked wikis, most recently
                          bookmarked first (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 128
                      my_pinned_wikis:
                        type: array
                        deprecated: true
                        description: DEPRECATED mirror of `my_bookmarked_wikis` —
                          the same cards, for native builds shipped before the pin→bookmark
                          rename. Read `my_bookmarked_wikis`.
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 128
                      recently_updated:
                        type: array
                        description: Published wikis with the latest edits (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              last_updated_at:
                                type: string
                                format: date-time
                      recently_viewed:
                        type: array
                        description: Non-admin only. Wikis the caller opened most
                          recently (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 128
                              viewed_at:
                                type: string
                                format: date-time
                      most_viewed_wikis:
                        type: array
                        description: Admin only. Published wikis ranked by all-time
                          views (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 1042
                      top_contributors:
                        type: array
                        description: Admin only. Authors ranked by pages created,
                          with the summed views of those pages (max 5).
                        items:
                          type: object
                          properties:
                            user_id:
                              type: integer
                              example: 42
                            user_name:
                              type: string
                              example: Dana Lee
                            user_image:
                              type: string
                              nullable: true
                              description: Absolute avatar URL, or null when unavailable.
                            wikis_created:
                              type: integer
                              example: 14
                            total_views:
                              type: integer
                              example: 903
                      recent_activity:
                        type: array
                        description: Admin only. Latest edit/publish/restore events
                          across the business (max 5).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 991
                            actor_name:
                              type: string
                              example: Dana Lee
                            actor_image:
                              type: string
                              nullable: true
                              description: Absolute avatar URL, or null when unavailable.
                            change_type:
                              type: string
                              description: Raw event type (WikiVersion#change_type).
                              enum:
                              - created
                              - updated
                              - minor_edit
                              - major_revision
                              - restored
                              - published
                              - ownership_changed
                              example: published
                            verb:
                              type: string
                              description: 'Display verb for the event, from `wikis_activity_verb`:
                                "created", "published", "restored", "revised" (major_revision),
                                "made a minor edit to" (minor_edit), "transferred
                                ownership of" (ownership_changed), or "updated" for
                                anything else.'
                              example: published
                            transferred_to:
                              type: string
                              nullable: true
                              description: New owner's name — present only for an
                                ownership transfer, null otherwise.
                            wiki_id:
                              type: integer
                              nullable: true
                              example: 17
                            wiki_title:
                              type: string
                              nullable: true
                              example: Shift Swap Guidelines
                            wiki_icon:
                              type: string
                              example: fas fa-book
                            wiki_color:
                              type: string
                              example: "#3b7ddd"
                            occurred_at:
                              type: string
                              format: date-time
                      needs_attention:
                        type: array
                        description: Admin only. Drafts pending publish across the
                          business (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              creator_name:
                                type: string
                                nullable: true
                                example: Dana Lee
                              last_updated_at:
                                type: string
                                format: date-time
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`). (The Wikis API is not scope-gated.)
  "/wikis/list":
    get:
      tags:
      - Wikis
      summary: Wikis list ("All Wikis")
      description: |
        The native-client mirror of the web "All Wikis" browse screen. Returns a
        paginated, filtered, ordered list of wikis. Visibility is scoped exactly
        like the web: a non-admin never sees another user's drafts or
        group-restricted pages.

        **Every filter returns a FLAT list** — no row ever carries a `sub_wikis`
        key. Each row is a card (title/color/icon) plus a `has_sub_wikis` boolean
        (does this page have at least one visible published child) so a client can
        show an expand affordance and fetch the children lazily via
        `GET /wikis/{id}/sub_wikis`.

        * `all` lists published ROOT wikis only (parent_id IS NULL). Its
          `filter_counts["all"]` badge counts the WHOLE published set — roots AND
          their sub-wikis — so it reads as the true "total wikis", even though the
          list itself shows only roots. `meta.total_count` stays the ROOT count so
          pagination over the list is correct.
        * Every OTHER filter is a flat list of matching wikis at any depth; its
          count == its list total.

        **Filters** (`filter`) are persona-aware. `filter_counts` carries the
        total per available filter:

        * **Every viewer:** `all` (default — published top-level roots), `draft`
          (a member sees only their own), `owned` ("Mine" — EVERYTHING the caller
          created, sub-pages and drafts included, matching the web browse and the
          dashboard's `my_wikis.total`), `bookmarked`, and `archived` (an admin sees
          every archived page in the business; a member sees only the ones they
          CREATED — the same admin/member split `draft` uses).
        * **Admin-tier viewers** additionally get `stale` (published past the
          freshness window). A member who requests that admin-only filter is
          served the `all` list (and `active_filter` echoes `all`).

        **Ordering** (`sort`): `position` (default — the admin-set Custom Order,
        i.e. the reorder target), `recent` (most recently updated), `title` (A–Z).
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: |-
          all (default) | draft | owned | bookmarked | stale | archived. `stale` is admin-only. `archived` is available to every persona but role-scopes its contents (admin: the whole business; member: only the pages they created). An unknown or forbidden value falls back to `all`.
          `pinned` is a DEPRECATED alias of `bookmarked`, kept for native builds shipped before the rename; it selects the identical wikis and is echoed back verbatim in `active_filter` so a pre-rename client can still match it against its own chip. New integrations must send `bookmarked`.
        schema:
          type: string
          enum:
          - all
          - draft
          - owned
          - bookmarked
          - pinned
          - stale
          - archived
          default: all
      - name: sort
        in: query
        required: false
        description: 'position (the admin-set Custom Order) | recent | title | popular
          | created. The DEFAULT is conditional, matching the web rail: `position`
          when `filter=all`, `recent` for every other filter. An unrecognised value
          falls back to `position`.'
        schema:
          type: string
          enum:
          - position
          - recent
          - title
          - popular
          - created
      - name: group_id
        in: query
        required: false
        description: |-
          Restrict the list to the wikis SHARED WITH ONE group — a `NotificationRecipientGroup` id, the same ids `GET /wikis/{id}` returns in its `groups` array. ORTHOGONAL to `filter`: the two compose, so `?filter=draft&group_id=7` is "drafts inside group 7".
          "In the group" means EXPLICITLY shared with it (the wiki's read visibility is restricted to that group). A wiki visible to *everyone* is readable by the group's members but is **not** in the group, so it is excluded — otherwise the filter would return nearly the whole tenant.
          The filter only ever NARROWS: read visibility still applies, so a caller naming a group whose wikis they cannot read gets an empty list rather than a peek inside it. A group id from another business likewise matches nothing.
          A well-formed but unknown id is APPLIED (empty list, and `group_id` echoed) rather than ignored — silently returning the unfiltered list after a caller asked for one group would be misleading. A blank, non-numeric or non-positive value IS ignored, and the response echoes `group_id: null` so the two cases are distinguishable.
        schema:
          type: integer
          minimum: 1
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Wikis list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wikis
                - active_filter
                - sort
                - filter_counts
                - meta
                properties:
                  active_filter:
                    type: string
                    description: The filter actually applied (may differ from the
                      request when a member asked for an admin-only filter).
                    example: all
                  sort:
                    type: string
                    example: position
                  group_id:
                    type: integer
                    nullable: true
                    description: The group filter actually applied, or `null` when
                      none was (a blank/non-numeric/non-positive `group_id` is ignored).
                      Lets a client tell "filtered to a group and it's empty" apart
                      from "my group_id was ignored".
                    example: 7
                  filter_counts:
                    type: object
                    description: |-
                      Total per available filter for this caller. Always carries all/draft/owned/bookmarked/archived; admin-tier callers also get `stale`. Every count equals its filter's list total EXCEPT `all`, which counts roots + sub-wikis (see below) while the `all` list shows only roots.
                      A DEPRECATED `pinned` key mirrors `bookmarked` and is emitted immediately after it, so a native build shipped before the rename still renders its chip row in the original order. Read `bookmarked`; ignore `pinned`.
                      `archived` is present for EVERY persona, but a member's count covers only the archived pages they created, while an admin's covers every archived page in the business.
                      When `group_id` is applied these counts are narrowed to that group too, so each chip keeps counting exactly what clicking it returns inside the group.
                    properties:
                      all:
                        type: integer
                        description: Roots + sub-wikis (the whole published set) —
                          the `all` list shows only roots, so this is >= meta.total_count.
                        example: 15
                      draft:
                        type: integer
                        example: 3
                      owned:
                        type: integer
                        example: 5
                      bookmarked:
                        type: integer
                        example: 2
                      pinned:
                        type: integer
                        example: 2
                        deprecated: true
                        description: Mirror of `bookmarked` for pre-rename native
                          builds. Always equal to it.
                      stale:
                        type: integer
                        example: 1
                        description: Admin-tier only.
                      archived:
                        type: integer
                        example: 4
                        description: 'Every persona. Admin: every archived page in
                          the business; member: only the ones they created.'
                  wikis:
                    type: array
                    description: A FLAT list of cards — one per matching wiki. For
                      `filter=all` these are published ROOT wikis only. No row carries
                      a `sub_wikis` key; each carries `has_sub_wikis` and children
                      are fetched via `GET /wikis/{id}/sub_wikis`.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/WikiCard"
                      - type: object
                        required:
                        - has_sub_wikis
                        properties:
                          has_sub_wikis:
                            type: boolean
                            description: True when this page has at least one visible
                              published child.
                            example: true
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the filter. For `all` this is the
                          ROOT count (the paginated list is root-only); the roots
                          + sub-wikis grand total is `filter_counts.all`.
                        example: 10
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`). (The Wikis API is not scope-gated.)
  "/wikis/search":
    get:
      tags:
      - Wikis
      summary: Search wikis (by text and/or tags)
      description: |
        Search across the knowledge base — the native mirror of the web Wikis
        search screen PLUS the browse rail's tag chips, in one endpoint. Both
        surfaces run the SAME shared scope, so they return the same hits in the
        same order.

        **Text search (`q`)** matches `title`, `description` and `body` with a
        case-insensitive substring match. The term is LIKE-escaped, so a literal
        `%` or `_` the user types matches literally instead of acting as a
        wildcard.

        **Tag search (`tags`)** filters by tag NAME, case-insensitively, against
        this business's tag vocabulary — the same tags returned in every hit's
        `tags` array and by `GET /api/v1/wikis/{id}`. Accepts a comma-separated
        list (`?tags=hr,policy`) or the repeated form
        (`?tags[]=hr&tags[]=policy`), up to 10 names; `tag` is accepted as an
        alias. With several tags, `match=any` (the default) returns wikis carrying
        **at least one** of them and `match=all` returns only wikis carrying
        **every** one.

        The two dimensions are independent and compose with AND:

        | Request | Result |
        |---|---|
        | `?q=zookeeper` | text search |
        | `?tags=runbook` | browse-by-tag — a real search, **not** the blank-query no-op |
        | `?q=zookeeper&tags=runbook` | the term, narrowed to pages tagged `runbook` |
        | neither | empty result set, 200 |

        A tag name this business does not have is never silently ignored — a
        filter that evaporated would hand the caller wikis that miss the tag they
        just narrowed to. With `match=any` the other, known names still apply
        ("tagged with at least one of them" is still satisfiable); with
        `match=all`, or when EVERY requested name is unknown, the result
        **narrows to nothing**, because no page can carry a tag this business does
        not have. The response echoes `tags` (the canonical names actually
        applied), `unknown_tags` (names that do not exist here) and `match`, so a
        client can tell "filtered and empty" apart from "my tag was ignored".

        **Visibility** mirrors the web on both dimensions: an admin-tier caller
        searches every status (drafts and archived included); everyone else
        searches published wikis plus their own, intersected with the app's
        read-visibility rules — so a member never finds another user's draft or a
        group-restricted page, tagged or not.

        A **blank or missing `q` with no `tags` returns an empty result set with
        200**, not an error, so a client may call this on every keystroke. Results
        are ordered newest-updated first.

        Each real search that carries a **term** is recorded for search analytics.
        A tag-only browse is not logged — those reports exist to surface gaps in
        what people typed.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Search term. Blank/omitted returns an empty result set (200)
          unless `tags` is supplied.
        schema:
          type: string
          example: zookeeper
      - name: tags
        in: query
        required: false
        description: 'Tag filter. Comma-separated (`hr,policy`) or repeated (`?tags[]=hr&tags[]=policy`).
          Matched on tag name, case-insensitively; blanks and duplicates are dropped
          and at most 10 names are considered. A name this business does not have
          is reported in `unknown_tags`: with `match=any` the remaining known names
          still apply, and with `match=all` — or when every name is unknown — the
          result set narrows to nothing rather than widening to the names that do
          exist.'
        schema:
          type: string
          example: hr,policy
      - name: tag
        in: query
        required: false
        description: Alias for `tags`, matching the web browse rail's `?tag=` chip
          link. Ignored when `tags` is present and non-blank.
        schema:
          type: string
          example: hr
      - name: match
        in: query
        required: false
        description: How several tags combine — `any` (tagged with at least one) or
          `all` (tagged with every one). Ignored when no tags are supplied; an unrecognised
          value falls back to `any`.
        schema:
          type: string
          enum:
          - any
          - all
          default: any
      - name: page
        in: query
        required: false
        description: 1-based page number.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Hits per page, 1–50.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Search executed successfully (may contain zero hits)
          content:
            application/json:
              schema:
                type: object
                required:
                - query
                - tags
                - unknown_tags
                - match
                - wikis
                - meta
                properties:
                  query:
                    type: string
                    description: The stripped term the server searched for (echoed
                      back).
                    example: zookeeper
                  tags:
                    type: array
                    description: The tag filter as APPLIED — canonical (stored-case)
                      tag names, de-duplicated and capped at 10. Empty when no tag
                      filter was requested, and also empty when every requested name
                      was unknown (in which case the result set is empty and the names
                      appear in `unknown_tags`). Not to be confused with each hit's
                      own `tags`.
                    items:
                      type: string
                    example:
                    - hr
                    - policy
                  unknown_tags:
                    type: array
                    description: Requested tag names with no matching tag in this
                      business (a typo, or a tag from another tenant). With `match=all`,
                      or when every requested name was unknown, their presence means
                      the result set was narrowed to nothing; with `match=any` the
                      known names still applied.
                    items:
                      type: string
                    example:
                    - hr-polices
                  match:
                    type: string
                    enum:
                    - any
                    - all
                    description: The multi-tag combination rule actually applied (an
                      unrecognised `match` falls back to `any`).
                    example: any
                  wikis:
                    type: array
                    description: Matching wikis, newest-updated first.
                    items:
                      "$ref": "#/components/schemas/WikiSearchHit"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 7
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`). (The Wikis API is not scope-gated.)
  "/wikis/{id}/sub_wikis":
    parameters:
    - name: id
      in: path
      required: true
      description: The parent wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: Sub-wikis of a wiki (one level)
      description: |
        Returns the immediate (ONE level) **published** sub-wikis of the given
        wiki — not the whole subtree. Each child carries its title / icon / color
        plus `has_sub_wikis` (whether that child has published children of its
        own, so a client can render an expand affordance without a second request
        per row).

        Visibility is scoped exactly like `GET /api/v1/wikis/list`: a non-admin
        never sees another user's drafts or group-restricted pages. The parent
        wiki is resolved within that visible scope, so an id the caller cannot see
        (restricted, archived-away, cross-tenant, or non-existent) returns `404`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Sub-wikis retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - sub_wikis
                properties:
                  wiki_id:
                    type: integer
                    description: The parent wiki id (echoes the path id).
                    example: 42
                  sub_wikis:
                    type: array
                    description: Immediate published children, ordered like the web
                      tree (position, then title).
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - icon
                      - color
                      - bookmarked
                      - has_sub_wikis
                      properties:
                        id:
                          type: integer
                          example: 57
                        title:
                          type: string
                          example: Onboarding
                        icon:
                          type: string
                          description: FontAwesome class; falls back to `fas fa-book`
                            when unset.
                          example: fas fa-door-open
                        color:
                          type: string
                          description: Whitelist-coerced safe hex; falls back to `#6c757d`.
                          example: "#2E7D32"
                        bookmarked:
                          type: boolean
                          description: Whether the calling user has bookmarked this
                            child wiki.
                          example: false
                        has_sub_wikis:
                          type: boolean
                          description: True when this child has published children
                            of its own.
                          example: true
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`). (The Wikis API is not scope-gated.)
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/viewers":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: People who viewed a wiki
      description: |
        Returns the distinct users who viewed the wiki, ordered by **view_count
        DESC** (most views first), with **most-recently-viewed** as the
        tiebreaker on an equal count. Aggregated per user with a real
        `view_count` and `last_viewed_at`.

        Mirrors the web "Viewed by N people" modal:
        * **Authorization** — admin-tier OR the wiki's creator only. A plain
          member viewing someone else's page must NOT learn who read it; they
          get `403 access_denied`.
        * **Search** — `search` filters by viewer name (ILIKE), like the modal's
          "Search people…" box. A `%`/`_` typed by the user matches literally.

        A view is deduplicated to at most one event per user per 30 minutes, so
        `view_count` is the number of distinct view sessions (not raw page loads).
        The wiki is resolved within the caller's visible scope, so an id they
        cannot see returns `404`.
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Filter viewers by name (case-insensitive substring).
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Viewers retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - viewers
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  viewers:
                    type: array
                    description: Distinct viewers, ordered by view_count DESC then
                      last_viewed_at DESC.
                    items:
                      type: object
                      required:
                      - user_id
                      - name
                      - image_url
                      - job_title
                      - view_count
                      - last_viewed_at
                      properties:
                        user_id:
                          type: integer
                          example: 1884
                        name:
                          type: string
                          example: Georgia Fitzpatrick
                        image_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (null if unavailable).
                          example: https://officechat.workforce.mangoapps.com/avatars/1884.png
                        job_title:
                          type: string
                          nullable: true
                          description: The viewer's job title in this business (null
                            if unset).
                          example: Data Coordinator
                        view_count:
                          type: integer
                          description: Number of distinct view sessions by this user
                            (30-min dedup).
                          example: 3
                        last_viewed_at:
                          type: string
                          format: date-time
                          description: When this user most recently viewed the wiki
                            (ISO-8601).
                          example: '2026-07-30T05:29:11Z'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Distinct viewer count (honors search).
                        example: 12
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller, OR the caller
            is neither an admin nor the wiki's creator (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/comments":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: Comment thread on a wiki
      description: |
        The wiki's comment thread, modelled on the feeds comment API. Returns a
        paginated list of TOP-LEVEL comments, each inlining its direct replies
        (threading is ONE level deep — a reply always has `replies: []`). Each
        comment carries its author, body (with raw `@[Name](mention:id)` tokens
        preserved + a deduped `mentioned_user_ids`), a reactions summary, and its
        file attachments.

        Soft-deleted comments are omitted entirely (they never appear in the
        thread or the count). `meta.total_count` counts TOP-LEVEL comments only
        (replies aren't counted). Ordering is `created_at ASC` (oldest first) for
        both comments and their replies. The wiki is resolved within the caller's
        visible scope, so an id they can't see returns `404`.
      security:
      - BearerAuth: []
      parameters:
      - name: parent_comment_id
        in: query
        required: false
        description: When present, returns the direct REPLIES of that comment instead
          of the top-level thread. Resolved through this wiki's own comments, so a
          parent id from another wiki's thread returns an empty list.
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: Comment thread retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - comments
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  comments:
                    type: array
                    description: Top-level comments (oldest first), each with inlined
                      one-level replies.
                    items:
                      "$ref": "#/components/schemas/WikiComment"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 10
                      total_count:
                        type: integer
                        description: Top-level comment count (excludes replies).
                        example: 24
                      total_pages:
                        type: integer
                        example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
    post:
      tags:
      - Wikis
      summary: Add a comment or reply to a wiki
      description: |
        Posts a comment on the wiki, or a reply to an existing top-level comment.
        Requires comment permission on the wiki
        (published, comments enabled, and the caller allowed by the wiki's
        `comment_permission`; otherwise 403 `comments_disabled`).

        **Attachments** — send files as multipart `attachments[]` (images shown
        as thumbnails, other files as download chips). Up to 5 files, 10 MB each.

        **Mentions** — same `@[Name](mention:id)` tokens the feeds/web composer
        emit: include them inline in `body` and the server parses them into the
        comment's `mentioned_user_ids` and notifies each mentioned user.

        **Threading is ONE level.** `parent_comment_id` must reference a
        TOP-LEVEL comment of THIS wiki; replying to a reply returns 422
        `reply_depth_exceeded`, and a `parent_comment_id` from another wiki
        returns 422 `parent_not_found`.

        On success returns the created comment in the same shape as the thread
        list rows (`{ comment: WikiComment }`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  description: Comment text; may contain `@[Name](mention:id)` mention
                    tokens. Max 2000 chars.
                  example: Great page @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
                  description: Top-level comment id to reply to. Omit for a new top-level
                    comment.
                attachments:
                  type: array
                  description: Up to 5 files, 10 MB each (PNG/JPG/WEBP/GIF/PDF/TXT/CSV/ZIP/Word/Excel).
                  items:
                    type: string
                    format: binary
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  example: Great page @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/WikiComment"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible, or the wiki does not permit
            the caller to comment (error code `comments_disabled`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '422':
          description: Validation failed (e.g. blank body), or an invalid `parent_comment_id`
            — `parent_not_found` (another wiki's comment) or `reply_depth_exceeded`
            (replying to a reply).
  "/wikis/{id}/reactors":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: People who reacted to a wiki (+ per-emoji counts)
      description: "Returns the users who reacted to the wiki, **most-recently-reacted\nfirst**,
        aggregated **per user** — one row per person carrying every\nemoji they left
        — plus the wiki's per-emoji `reaction_counts`.\n\nMirrors the web \"who reacted\"
        sheet (`Platform::ReactorsController`,\nopened from the reaction bar's summary),
        with two improvements: the web\nreturns one row *per reaction* (a person who
        left \U0001F44D and ❤️ appears\ntwice) and caps at a flat 200 rows; this endpoint
        aggregates per user and\npaginates.\n\n* **Authorization — anyone who can
        open the wiki.** Note this is\n  deliberately *looser* than the sibling `GET
        /wikis/{id}/viewers`, which\n  is admin/creator-only: who **viewed** a page
        is private, but who\n  **reacted** is public on the web to every reader. The
        wiki is resolved\n  within the caller's visible scope, so an id they cannot
        see returns\n  `404` rather than revealing that it exists.\n* **Search** —
        `search` filters reactors by name, first name, last name or\n  email (case-insensitive
        substring). A `%`/`_` typed by the user matches\n  literally. Identical to
        the comment-level sibling: both go through the\n  same `Platform::Reactable#reactors_scope`,
        so the two endpoints cannot\n  differ on which columns match.\n* **`reaction_counts`
        and `total_reactions` describe the WHOLE wiki and\n  are NOT narrowed by `search`**
        — they are the reaction bar's summary,\n  so they must not change while the
        user types in the people filter.\n  Only `meta.total_count` and the `reactors`
        list honor `search`.\n\nWikis accept the emoji set \U0001F44D ❤️ \U0001F389
        \U0001F440 \U0001F4A1 (`Wiki.reactable_emoji_set`).\n"
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Filter reactors by name / first name / last name / email (case-insensitive
          substring).
        schema:
          type: string
        example: Dana
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, maximum 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Reactors retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - reactors
                - reaction_counts
                - total_reactions
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for the whole wiki, e.g. `{\"\U0001F44D\":
                      4, \"❤️\": 2}`. NOT narrowed by `search`. Empty object when
                      nobody has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on the
                      wiki, not the number of people. NOT narrowed by `search`.
                    example: 6
                  reactors:
                    type: array
                    description: One row per USER, most-recently-reacted first. Honors
                      `search` and pagination.
                    items:
                      type: object
                      required:
                      - user_id
                      - name
                      - job_title
                      - image_url
                      - reactions
                      - reaction_count
                      - last_reacted_at
                      properties:
                        user_id:
                          type: integer
                          example: 1884
                        name:
                          type: string
                          description: Full name, falling back to the display name.
                          example: Dana Lee
                        job_title:
                          type: string
                          nullable: true
                          description: The reactor's job title in this business (null
                            if unset). Resolves both the normalized job title and
                            the legacy free-text value.
                          example: Store Manager
                        image_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (null if unavailable).
                          example: https://officechat.workforce.mangoapps.com/avatars/1884.png
                        reactions:
                          type: array
                          description: Every emoji this person left on the wiki, oldest
                            first. Always non-empty.
                          items:
                            type: string
                          example:
                          - "\U0001F44D"
                          - "❤️"
                        reaction_count:
                          type: integer
                          description: How many reactions this person left (>= 1).
                          example: 2
                        last_reacted_at:
                          type: string
                          format: date-time
                          description: When this user most recently reacted (ISO-8601).
                          example: '2026-07-30T05:29:11Z'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Distinct reactor count (honors search).
                        example: 5
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        description: 0 when there are no reactors.
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
            Unlike `/wikis/{id}/viewers`, being a non-admin is NOT a 403 here.
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/reactions":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Toggle the caller's emoji reaction on a wiki
      description: "Adds or removes the caller's emoji reaction on a wiki page — this
        is a\n**toggle** (the platform `Reactable#toggle_reaction` contract and the
        web\nreaction bar in `shared/_platform_reaction_bar`): sending an emoji the\ncaller
        has **not** left ADDS it; sending one they **already** left REMOVES\nit. A
        user may hold several DIFFERENT emojis at once — each emoji is its\nown reaction
        toggled independently.\n\nThe allowed emoji set is \U0001F44D ❤️ \U0001F389
        \U0001F440 \U0001F4A1 (`Wiki.reactable_emoji_set`); any\nother value returns
        `422 invalid_emoji`.\n\n* **Authorization — anyone who can open the wiki.**
        Same gate as\n  `GET /wikis/{id}/reactors` (deliberately *looser* than\n  `GET
        /wikis/{id}/viewers`): the reaction bar is public to every reader\n  on the
        web. The wiki is resolved within the caller's visible scope, so\n  an id they
        cannot see returns `404` rather than revealing that it\n  exists. (Not scope-gated
        — any authenticated caller with app access.)\n\nThe response echoes the updated
        reaction-bar summary\n(`reaction_counts`, `total_reactions`, `my_reactions`)
        so the client can\nrepaint the bar without a follow-up `GET`.\n"
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: "One of the allowed emoji (\U0001F44D ❤️ \U0001F389
                    \U0001F440 \U0001F4A1)."
                  example: "\U0001F44D"
      responses:
        '200':
          description: Reaction toggled. `reacted` is `true` when the caller's reaction
            is now present, `false` when it was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - emoji
                - reacted
                - reaction_counts
                - total_reactions
                - my_reactions
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  emoji:
                    type: string
                    description: The emoji that was toggled.
                    example: "\U0001F44D"
                  reacted:
                    type: boolean
                    description: True if the caller's reaction is now present; false
                      if it was removed.
                    example: true
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for the whole wiki after the toggle,
                      e.g. `{\"\U0001F44D\": 4, \"❤️\": 2}`. Empty object when nobody
                      has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on the
                      wiki (not the number of people).
                    example: 6
                  my_reactions:
                    type: array
                    description: The emojis the CALLER currently has on this wiki.
                    items:
                      type: string
                    example:
                    - "\U0001F44D"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '422':
          description: The `emoji` is missing (error code `emoji_required`) or outside
            the allowed set (error code `invalid_emoji`; the response `error.details.allowed`
            lists the accepted emoji).
  "/wikis/{id}/bookmark":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Bookmark a wiki for the current user
      description: |
        Bookmarks the wiki for the **calling user**, writing the platform-wide
        `Platform::Bookmark` shared with the `/bookmarks` page and the web
        bookmark button. **Idempotent** — bookmarking an already-bookmarked wiki
        is a no-op that still returns `bookmarked: true` and never creates a
        second row.

        **Supersedes `POST /wikis/{id}/pin`**, which is still served as a
        deprecated alias of this endpoint for native builds shipped before the
        rename (same rows, same contract). The Wikis app used to carry an
        app-local pin alongside Bookmarks — two ways to mark the same page — and
        the pin was removed. Clients calling the old path must move to this one.

        * **Authorization — anyone who can open the wiki.** The wiki is resolved
          within the caller's visible scope, so an id they cannot see returns
          `404` rather than revealing that it exists. Not scope-gated: any
          authenticated caller with Wikis-app access may bookmark.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki bookmarked (or already bookmarked).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - bookmarked
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  bookmarked:
                    type: boolean
                    description: The bookmark state for the caller AFTER this call
                      (always `true` here).
                    example: true
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
    delete:
      tags:
      - Wikis
      summary: Remove the current user's bookmark on a wiki
      description: |
        Removes the calling user's bookmark on the wiki. **Idempotent** —
        removing a bookmark that isn't there is a no-op that still returns
        `bookmarked: false`. Same authorization as the POST verb.

        A bookmark carrying a note is soft-deleted (recoverable from the
        platform Trash); a bare one is removed outright, matching the web
        toggle.

        **Supersedes `DELETE /wikis/{id}/pin`**, which is still served as a
        deprecated alias of this endpoint for native builds shipped before the
        rename (same rows, same contract).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Bookmark removed (or there was none).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - bookmarked
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  bookmarked:
                    type: boolean
                    description: The bookmark state for the caller AFTER this call
                      (always `false` here).
                    example: false
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/archive":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Archive a wiki
      description: |
        Archives the wiki — sets its status to `archived`, mirroring the web
        "Archive" action (Apps::Wikis::PagesController#update with
        `wiki[status]=archived`). There is no discard/soft-delete: an archived
        wiki drops out of the default browse tree but remains restorable.
        **Idempotent** — archiving an already-archived wiki is a no-op that
        still returns status `archived`.

        * **Authorization — the web's status-change gate, faithfully** (its
          `can_manage_wiki?` AND `can_edit_wiki?`, the same pair the unarchive
          twin below documents): a business **admin (or above)** may always
          archive; the wiki's **creator** may archive **only while the wiki is
          not locked** (a locked wiki fails `can_edit_wiki?` for a non-admin).
          Any other caller who can see the wiki gets `403` (`forbidden`); a
          caller who cannot even see the wiki gets `404` (resolved out by the
          visible scope, never revealing it exists). This is a per-wiki check,
          NOT a token scope.
        * **A wiki someone else is actively editing answers `409`**, not `403` —
          the lock is transient, so retrying after it clears succeeds.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki archived (or already archived).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - status
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  status:
                    type: string
                    description: The wiki's status after the call.
                    enum:
                    - archived
                    example: archived
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller may not archive this wiki — they are neither a business
            admin nor its (unlocked-wiki) creator (error code `forbidden`), or the
            Wikis app is not accessible to them (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '409':
          description: Another user currently holds the edit lock on this wiki (error
            code `wiki_being_edited`); the message names the lock holder. Distinct
            from `403` because the condition is transient — retry once the lock clears.
    delete:
      tags:
      - Wikis
      summary: Unarchive a wiki
      description: |
        Unarchives the wiki — sets its status back to `draft`, mirroring the web
        "Restore to Draft" action (Apps::Wikis::PagesController#update with
        `wiki[status]=draft`). It restores to **draft** (NOT `published`), so the
        owner can review before re-publishing. **Idempotent** — calling it on a
        wiki that isn't archived is a no-op that returns the wiki's current
        status unchanged.

        * **Authorization — the web's status-change gate, faithfully** (its
          `can_manage_wiki?` AND `can_edit_wiki?`): a business **admin (or above)**
          may always unarchive; the wiki's **creator** may unarchive **only while
          the wiki is not locked** (a locked wiki fails `can_edit_wiki?` for a
          non-admin, so a locked archived page can be restored by an admin only).
          Any other caller who can see the wiki gets `403` (`forbidden`); a caller
          who cannot even see it gets `404`. Note that an archived wiki is visible
          only to admins and its creator, so a non-creator member typically gets
          `404` here rather than `403`. This is a per-wiki check, NOT a token scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki unarchived (or already not archived).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - status
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  status:
                    type: string
                    description: The wiki's status after the call — `draft` after
                      restoring an archived wiki, or its unchanged current status
                      on a no-op.
                    enum:
                    - draft
                    - published
                    example: draft
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller may not unarchive this wiki — they are neither a
            business admin nor its (unlocked-wiki) creator (error code `forbidden`),
            or the Wikis app is not accessible to them (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '409':
          description: Another user currently holds the edit lock on this wiki (error
            code `wiki_being_edited`); the message names the lock holder. Distinct
            from `403` because the condition is transient — retry once the lock clears.
  "/wikis/{wiki_id}/comments/{id}/reactions":
    parameters:
    - name: wiki_id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment id. A reply is the same comment model (threaded), so
        this handles comments AND replies.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Toggle the caller's emoji reaction on a wiki comment or reply
      description: "Adds or removes the caller's emoji reaction on a wiki **comment**
        — or a\n**reply**, which is the same threaded comment model, so this one endpoint\nhandles
        both. This is a **toggle** (the platform\n`Reactable#toggle_reaction` contract
        and the web comment reaction bar in\n`Apps::Wikis::CommentsController#react`):
        sending an emoji the caller has\n**not** left ADDS it; sending one they **already**
        left REMOVES it. A user\nmay hold several DIFFERENT emojis at once — each
        is toggled independently.\n\nThe allowed emoji set is \U0001F44D ❤️ \U0001F389
        \U0001F440 \U0001F4A1 (`WikiComment.reactable_emoji_set`);\nany other value
        returns `422 invalid_emoji`.\n\n* **Authorization — anyone who can open the
        wiki** (same as the page\n  reaction bar). The wiki is resolved within the
        caller's visible scope,\n  and the comment is resolved THROUGH that wiki's
        own thread, so a\n  restricted / cross-tenant / foreign / missing id all return
        `404`\n  rather than revealing that it exists. (Not scope-gated.)\n\nThe response
        echoes the updated reaction-bar summary for the comment\n(`reaction_counts`,
        `total_reactions`, `my_reactions`) so the client can\nrepaint the bar without
        a follow-up `GET`.\n"
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: "One of the allowed emoji (\U0001F44D ❤️ \U0001F389
                    \U0001F440 \U0001F4A1)."
                  example: "\U0001F44D"
      responses:
        '200':
          description: Reaction toggled. `reacted` is `true` when the caller's reaction
            is now present, `false` when it was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - comment_id
                - emoji
                - reacted
                - reaction_counts
                - total_reactions
                - my_reactions
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path).
                    example: 42
                  comment_id:
                    type: integer
                    description: The comment (or reply) id (echoes the path).
                    example: 57
                  emoji:
                    type: string
                    description: The emoji that was toggled.
                    example: "\U0001F44D"
                  reacted:
                    type: boolean
                    description: True if the caller's reaction is now present; false
                      if it was removed.
                    example: true
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for the comment after the toggle,
                      e.g. `{\"\U0001F44D\": 4, \"❤️\": 2}`. Empty object when nobody
                      has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on the
                      comment.
                    example: 6
                  my_reactions:
                    type: array
                    description: The emojis the CALLER currently has on this comment.
                    items:
                      type: string
                    example:
                    - "\U0001F44D"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller, or no such comment
            on that wiki (error code `not_found`).
        '422':
          description: The `emoji` is missing (error code `emoji_required`) or outside
            the allowed set (error code `invalid_emoji`; the response `error.details.allowed`
            lists the accepted emoji).
  "/wikis/{wiki_id}/comments/{id}/reactors":
    parameters:
    - name: wiki_id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment id. A reply is the same comment model (threaded), so
        this handles comments AND replies.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: People who reacted to a wiki comment (+ per-emoji counts)
      description: "Returns the users who reacted to one wiki **comment or reply**,\n**most-recently-reacted
        first**, aggregated **per user** — one row per\nperson carrying every emoji
        they left — plus that comment's per-emoji\n`reaction_counts`.\n\nThis is the
        read side of `POST /wikis/{wiki_id}/comments/{id}/reactions`,\nwhich reports
        only the caller's own state and the totals. It returns the\n**same row and
        `meta` shape** as the page-level\n`GET /wikis/{id}/reactors`, so one client
        parser handles both.\n\n* **Replies are the same endpoint.** A reply is the
        same comment model\n  (threaded via `parent_comment_id`, still attached to
        the wiki), so pass\n  either a top-level comment id or a reply id. A reply's
        reactors are\n  independent of its parent's.\n* **Authorization — anyone who
        can open the wiki.** Matching both the web\n  comment reaction bar and the
        page-level reactors endpoint, and\n  deliberately *looser* than `GET /wikis/{id}/viewers`,
        which is\n  admin/creator-only: who **viewed** a page is private, who **reacted**
        is\n  not. The wiki is resolved within the caller's visible scope and the\n
        \ comment through that wiki's own thread, so a restricted, cross-tenant,\n
        \ cross-wiki or missing id all return `404` alike — never revealing that\n
        \ something the caller cannot see exists.\n* **Search** — `search` filters
        reactors by name, first name, last name or\n  email (case-insensitive substring).
        A `%`/`_` typed by the user matches\n  literally.\n* **`reaction_counts` and
        `total_reactions` describe the WHOLE comment and\n  are NOT narrowed by `search`**
        — they are the comment's reaction-bar\n  summary, so they must not change
        while the user types in the people\n  filter. Only `meta.total_count` and
        the `reactors` list honor `search`.\n* **Counts are scoped to this comment**
        — a reaction on the wiki *page*, or\n  on a sibling comment or reply, never
        appears here.\n\nComments accept the emoji set \U0001F44D ❤️ \U0001F389 \U0001F440
        \U0001F4A1\n(`WikiComment.reactable_emoji_set`).\n\nQuery cost is flat at
        5 queries regardless of page size — every field a row\ncarries is resolved
        from a preloaded batch.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Filter reactors by name / first name / last name / email (case-insensitive
          substring).
        schema:
          type: string
        example: Dana
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, maximum 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Comment reactors retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - comment_id
                - reactors
                - reaction_counts
                - total_reactions
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path `wiki_id`).
                    example: 42
                  comment_id:
                    type: integer
                    description: The comment (or reply) id (echoes the path `id`).
                    example: 907
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for this COMMENT, e.g. `{\"\U0001F44D\":
                      4, \"❤️\": 2}`. NOT narrowed by `search`, and never includes
                      reactions left on the wiki page or on a sibling comment/reply.
                      Empty object when nobody has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on this
                      comment, not the number of people. NOT narrowed by `search`.
                    example: 6
                  reactors:
                    type: array
                    description: One row per USER, most-recently-reacted first. Honors
                      `search` and pagination.
                    items:
                      type: object
                      required:
                      - user_id
                      - name
                      - job_title
                      - image_url
                      - reactions
                      - reaction_count
                      - last_reacted_at
                      properties:
                        user_id:
                          type: integer
                          example: 1884
                        name:
                          type: string
                          description: Full name, falling back to the display name.
                            `"Unknown"` for a reaction left by someone no longer an
                            active member of this business.
                          example: Dana Lee
                        job_title:
                          type: string
                          nullable: true
                          description: The reactor's job title in this business (null
                            if unset). Resolves both the normalized job title and
                            the legacy free-text value.
                          example: Store Manager
                        image_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (null if unavailable).
                          example: https://officechat.workforce.mangoapps.com/avatars/1884.png
                        reactions:
                          type: array
                          description: Every emoji this person left on THIS comment,
                            oldest first. Always non-empty.
                          items:
                            type: string
                          example:
                          - "\U0001F44D"
                          - "❤️"
                        reaction_count:
                          type: integer
                          description: How many reactions this person left (>= 1).
                          example: 2
                        last_reacted_at:
                          type: string
                          format: date-time
                          description: When this user most recently reacted to this
                            comment (ISO-8601).
                          example: '2026-07-30T05:29:11Z'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Distinct reactor count (honors search).
                        example: 5
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        description: 0 when there are no reactors.
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
            Unlike `/wikis/{id}/viewers`, being a non-admin is NOT a 403 here.
        '404':
          description: No wiki with that id is visible to the caller, or no such comment
            on that wiki (error code `not_found`).
  "/wikis/{wiki_id}/comments/{id}":
    parameters:
    - name: wiki_id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment id. A reply is the same threaded comment model, so
        this handles comments AND replies.
      schema:
        type: integer
    patch:
      tags:
      - Wikis
      summary: Edit a wiki comment or reply
      description: |
        Edits the body of a comment (or reply — the same threaded model). Allowed
        for the **author within 15 minutes** of posting, or an **admin** of the
        wiki's business at any time; otherwise 403 `cannot_edit`.

        **Mentions** are re-parsed from the new `body` — the same
        `@[Name](mention:id)` tokens as create — so `mentioned_user_ids` reflects
        the edited text. **Attachments** sent as multipart `attachments[]` are
        APPENDED to the comment (existing attachments are kept). The edit stamps
        `edited_at`, which the read API exposes so clients can show an "edited"
        marker. No notifications are fired on edit.

        On success returns the updated comment in the same shape as the thread
        list rows (`{ comment: WikiComment }`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: New comment text; may contain `@[Name](mention:id)`
                    mention tokens. Max 2000 chars.
                  example: Updated — thanks @[Casey Poster](mention:49290)
                attachments:
                  type: array
                  description: Additional files to append (up to 5, 10 MB each).
                  items:
                    type: string
                    format: binary
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  example: Updated — thanks @[Casey Poster](mention:49290)
      responses:
        '200':
          description: Comment updated
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/WikiComment"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible, or the caller may no longer
            edit this comment — not the author, or past the 15-minute edit window
            (error code `cannot_edit`).
        '404':
          description: No wiki with that id is visible to the caller, or no comment
            with that id exists on this wiki (error code `not_found`).
        '422':
          description: Validation failed (e.g. blank body).
    delete:
      tags:
      - Wikis
      summary: Delete a wiki comment or reply
      description: |
        Soft-deletes a comment (or reply — the same threaded model). Allowed for
        the **author within 5 minutes** of posting, or an **admin** of the wiki's
        business at any time; otherwise 403 `cannot_delete`.

        The delete is soft: the row leaves the thread immediately (subsequent GETs
        omit it and it stops counting toward `meta.total_count`) but is retained
        for the audit window. Deleting a top-level comment removes its replies from
        the thread as well.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - deleted
                properties:
                  id:
                    type: integer
                    example: 8842
                  deleted:
                    type: boolean
                    example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible, or the caller may no longer
            delete this comment — not the author, or past the 5-minute delete window
            (error code `cannot_delete`).
        '404':
          description: No wiki with that id is visible to the caller, or no comment
            with that id exists on this wiki (error code `not_found`).
  "/wikis/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: Full detail for one wiki (records a view)
      description: |
        Full detail for a single wiki — the native mirror of the web reader pane
        (`apps/wikis/shared/_wiki_viewer`).

        * **Authorization — only users who may VIEW the wiki.** An admin sees any
          status; a member sees a published wiki visible to them (everyone, a
          group they belong to, or their own page of any status). Any wiki they
          cannot view — restricted, another user's draft, another tenant's, or a
          missing id — returns `404` identically, so the endpoint never reveals
          that a wiki exists.
        * **View count.** Like the web reader, a successful read records a
          `WikiViewEvent` (deduplicated to one per user per 30 minutes) and
          increments `views_count`. The `views_count` in THIS response is the
          pre-increment figure (the view is recorded after the payload is built).
        * `can_archive` / `can_delete` reflect the web `can_manage_wiki?` gate:
          `true` for a business admin-or-above OR the wiki's creator.
          `can_archive` is additionally `false` when the wiki is already archived.
        * `comments_enabled` is whether the page is open to comments at all,
          independent of the caller — `false` only when **"Who can comment?"** is
          `nobody`. `can_comment` resolves that same setting for THIS caller, folding
          in publish state; it is the same predicate the write path enforces, so a
          comment composer rendered from it can never 403 on submit. The two together
          tell "comments are off for this page" apart from "you specifically may not
          comment". (The stored **"Enable comments"** master toggle that
          `comments_enabled` used to report was removed as a duplicate of
          **"Who can comment?"**; the field is unchanged in name, type and meaning
          and is now derived from that setting.)
        * `author` is the byline, honouring the page's **"Show Author"** setting
          (null when off); `creator` is the ownership fact and is always present.
        * `show_toc` is the page's stored **"Show Table of Contents"** setting; the
          ToC entries themselves are not part of this payload.
        * `reaction_counts` is the per-emoji breakdown; `total_reactions` its
          sum; `distinct_reactions` the number of distinct emoji;
          `current_user_reactions` the emoji the CALLER left.
        * `bookmarked` is whether the **CALLER** has bookmarked this wiki (a
          bookmark is per-user, so it is never "somebody bookmarked it"). It is
          the same key `POST` / `DELETE /wikis/{id}/bookmark` return, so the
          toggle response can be
          written straight back onto a detail you are holding.
        * `parents` is the wiki's ancestor chain for a breadcrumb — **root first**,
          excluding the wiki itself, `[]` for a top-level wiki. Ancestors the
          caller cannot view are **omitted**, so the chain can have gaps: a
          published page may hang under another user's draft, and crumbing it
          would hand the caller the title of a page this endpoint would itself
          `404`. Combine with `sub_wikis` to place the wiki in the tree —
          `parents` looks up, `sub_wikis` looks down.
        * `sub_wikis` is **capped at 200 nodes**, cut breadth-first so the tree you
          get is always connected (a node appears only if its parent does).
          `sub_wikis_total` is how many nodes the subtree really holds and
          `sub_wikis_truncated` says whether the cap bit; both are additive, so a
          client that ignores them is unaffected. Nothing shipping today reaches
          the cap — the largest subtree measured across the fleet is well under it
          — it bounds a response that otherwise grows with nothing but how many
          sub-pages someone filed under one wiki.
        * **Deleting is reversible and the API owns both halves:**
          `DELETE /wikis/{id}` moves the page and its sub-tree to Trash,
          `GET /wikis/trash` lists what is in there and
          `POST /wikis/{id}/restore` brings a page (and its cascade) back.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki detail retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki
                properties:
                  wiki:
                    type: object
                    required:
                    - id
                    - title
                    - image_url
                    - web_view_url
                    - copy_link_url
                    - status
                    - is_archived
                    - last_updated_at
                    - current_version
                    - comments_count
                    - views_count
                    - creator
                    - show_author
                    - author
                    - groups
                    - tags
                    - show_toc
                    - can_archive
                    - can_delete
                    - comments_enabled
                    - can_comment
                    - total_reactions
                    - distinct_reactions
                    - reaction_counts
                    - current_user_reactions
                    - bookmarked
                    - attachments
                    - parents
                    - sub_wikis
                    - sub_wikis_total
                    - sub_wikis_truncated
                    properties:
                      id:
                        type: integer
                        example: 42
                      title:
                        type: string
                        example: Apache Zookeeper
                      image_url:
                        type: string
                        nullable: true
                        description: Absolute cover-image URL (null when none is attached).
                        example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/x/cover.png
                      web_view_url:
                        type: string
                        nullable: true
                        description: Absolute URL of the MOBILE web view (`/m/apps/wikis/:id`)
                          — the bare, description-only reader meant to be embedded
                          in a native WebView. Always carries `?embed=1`, which is
                          what strips the web chrome; load this url VERBATIM. Dropping
                          the param falls back to User-Agent detection, and a WebView
                          whose UA carries no MangoApps token then renders the full
                          mobile chrome (a second top bar plus the bottom tab bar)
                          inside the native screen.
                        example: https://officechat.workforce.mangoapps.com/m/apps/wikis/42?embed=1
                      copy_link_url:
                        type: string
                        nullable: true
                        description: Absolute shareable permalink to this page — the
                          same link the web reader's "Copy link" button copies (`/apps/wikis/pages/:id`).
                        example: https://officechat.workforce.mangoapps.com/apps/wikis/pages/42
                      status:
                        type: string
                        enum:
                        - draft
                        - published
                        - archived
                        example: published
                      is_archived:
                        type: boolean
                        description: 'Whether the wiki is archived — `status == "archived"`
                          as a boolean, so a client can branch without string-matching.
                          Pair it with `can_archive`, which is false both for an already-archived
                          page and for a caller who may not archive: `is_archived`
                          is what tells you whether to offer **Archive** or **Unarchive**
                          (`POST` / `DELETE /wikis/{id}/archive`).'
                        example: false
                      last_updated_at:
                        type: string
                        format: date-time
                        description: When the wiki was last updated (ISO-8601).
                        example: '2026-07-30T05:29:11Z'
                      current_version:
                        type: integer
                        description: Highest version number recorded (0 when the wiki
                          has no versions).
                        example: 3
                      comments_count:
                        type: integer
                        description: |-
                          Number of (non-deleted) TOP-LEVEL comments on the wiki. Replies are NOT counted — every implementation (details#show and all three web call sites) computes `wiki_comments.top_level.count`, and this line said "replies included" until 2026-09-05. The thread endpoint's own `meta.total_count` uses the same rule, so the two agree.

                          **`0` whenever `comments_enabled` is false.** "Who can comment? → Nobody" retracts the thread everywhere — the web reader replaces it with "Comments are disabled on this wiki." and `GET /wikis/{id}/comments` returns an empty list — so this counts what the app will actually show, never a retracted thread. The two fields can no longer contradict each other in one response.
                        example: 5
                      views_count:
                        type: integer
                        description: Total views (pre-increment for this read).
                        example: 128
                      creator:
                        type: object
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 1884
                          name:
                            type: string
                            example: Dana Lee
                      show_author:
                        type: boolean
                        description: The page's own "Show Author" setting (Properties).
                          When false the web reader prints no byline — and `author`
                          below is null.
                        example: true
                      author:
                        type: object
                        nullable: true
                        description: 'The byline to render: the same person as `creator`,
                          but **null when `show_author` is false**. Use this for the
                          byline and `creator` for ownership — the author can hide
                          the byline without the page becoming ownerless, so `creator`
                          is always reported.'
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 1884
                          name:
                            type: string
                            example: Dana Lee
                      groups:
                        type: array
                        description: Visibility groups gating the wiki (empty for
                          everyone / just_me visibility).
                        items:
                          type: object
                          required:
                          - id
                          - name
                          properties:
                            id:
                              type: integer
                              example: 7
                            name:
                              type: string
                              example: Engineering
                      tags:
                        type: array
                        description: The wiki's tags, sorted by name.
                        items:
                          type: object
                          required:
                          - id
                          - name
                          properties:
                            id:
                              type: integer
                              example: 12
                            name:
                              type: string
                              example: devops
                      can_archive:
                        type: boolean
                        description: May the caller archive this wiki (admin or creator,
                          and not already archived).
                        example: true
                      can_delete:
                        type: boolean
                        description: May the caller delete this wiki (admin or creator).
                        example: true
                      comments_enabled:
                        type: boolean
                        description: 'Whether the page is open to comments at all,
                          independent of the caller — `false` only when **"Who can
                          comment?"** is `nobody`, which is how a page closes comments.
                          Use `can_comment` for whether THIS caller may actually post;
                          the two differ on a page that is open but restricted (`admin_and_creator`).
                          On a `nobody` page they agree: `comments_enabled: false`
                          forces `can_comment: false` for **every** caller, admins
                          included — the closed-thread check runs BEFORE the admin
                          override, matching the web reader, which replaces the whole
                          thread with "Comments are disabled on this wiki." for every
                          persona. Formerly the stored **"Enable comments"** master
                          toggle, removed as a duplicate of **"Who can comment?"**
                          and now derived from it — same name, type and meaning.'
                        example: true
                      can_comment:
                        type: boolean
                        description: May the CALLER post a comment. `true` only when
                          the wiki is **published** and the page's **"Who can comment?"**
                          setting admits them (`any_user` → anyone, `admin_and_creator`
                          → the creator, `nobody` → **no one, including admins**).
                          The `nobody` check runs BEFORE the admin override, so an
                          admin does NOT keep a composer on a page closed to comments;
                          on every other setting an admin bypasses the restriction.
                          This is the exact predicate `POST /wikis/{id}/comments`
                          enforces, so a composer rendered on this flag can never
                          be refused on submit.
                        example: true
                      show_toc:
                        type: boolean
                        description: The page's stored **"Show Table of Contents"**
                          setting. The ToC entries themselves are not part of this
                          payload — this flag only mirrors the author's choice.
                        example: true
                      total_reactions:
                        type: integer
                        description: Total reactions on the wiki (sum of reaction_counts).
                        example: 6
                      distinct_reactions:
                        type: integer
                        description: Number of distinct emoji used (keys of reaction_counts).
                        example: 3
                      reaction_counts:
                        type: object
                        additionalProperties:
                          type: integer
                        description: "Count per emoji (e.g. four \U0001F44D and two
                          ❤️). Empty when nobody has reacted."
                        example:
                          "\U0001F44D": 4
                          "❤️": 2
                      current_user_reactions:
                        type: array
                        description: The emoji the CALLER left on this wiki (empty
                          if none).
                        items:
                          type: string
                        example:
                        - "\U0001F44D"
                      bookmarked:
                        type: boolean
                        description: Whether the CALLER has bookmarked this wiki.
                          A bookmark is per-user, so this is the caller's own state
                          and never "somebody bookmarked it" — render the reader's
                          bookmark toggle from it on first paint. Same key POST/DELETE
                          /wikis/{id}/bookmark return. Supersedes the former `pinned`
                          key, which is still mirrored below for pre-rename builds.
                        example: true
                      pinned:
                        type: boolean
                        deprecated: true
                        description: DEPRECATED mirror of `bookmarked`, kept for native
                          builds shipped before the pin→bookmark rename. Computed
                          from the same predicate, so the two can never disagree.
                          New integrations read `bookmarked`.
                        example: true
                      attachments:
                        type: array
                        description: The wiki's page-level attachments (max 5). Each
                          carries an absolute `url` and `download_url`; `is_image`
                          is true for images the client can render inline and false
                          for download chips.
                        items:
                          type: object
                          required:
                          - id
                          - filename
                          - content_type
                          - byte_size
                          - is_image
                          - url
                          - download_url
                          properties:
                            id:
                              type: integer
                              example: 2633
                            filename:
                              type: string
                              example: runbook.pdf
                            content_type:
                              type: string
                              nullable: true
                              example: application/pdf
                            byte_size:
                              type: integer
                              example: 30553
                            is_image:
                              type: boolean
                              example: false
                            url:
                              type: string
                              nullable: true
                              description: 'Absolute URL for opening the attachment
                                (null if the blob is unreadable). An IMAGE is served
                                `Content-Disposition: inline` so it can be previewed
                                in place; every other file — a PDF above all — is
                                served `Content-Disposition: attachment`, because
                                a client with no built-in PDF renderer can neither
                                display an inline PDF nor hand it to a download, so
                                the tap does nothing at all.'
                              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/x/runbook.pdf?disposition=attachment
                            download_url:
                              type: string
                              nullable: true
                              description: 'Absolute URL that ALWAYS responds `Content-Disposition:
                                attachment` — the target for an explicit Download
                                action, images included. Identical to `url` for every
                                non-image attachment.'
                              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/x/runbook.pdf?disposition=attachment
                      parents:
                        type: array
                        description: The wiki's ancestor chain, ROOT FIRST and excluding
                          the wiki itself — render it as breadcrumbs above the title.
                          Empty for a top-level wiki. Ancestors the caller cannot
                          view are DROPPED (so the chain may skip a level) rather
                          than leaking the title of a page this endpoint would 404;
                          a trashed ancestor truncates the chain there.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          properties:
                            id:
                              type: integer
                              example: 4188
                            title:
                              type: string
                              example: Employee Handbook
                        example:
                        - id: 4188
                          title: Employee Handbook
                        - id: 4192
                          title: Benefits
                        - id: 4201
                          title: Dental
                      sub_wikis:
                        type: array
                        description: |-
                          The nested tree of this wiki's sub-wikis — every published, caller-visible descendant, recursively. Each node is a card that itself carries a `sub_wikis` array (empty at the leaves). A descendant reachable only through a hidden (draft/restricted) parent does not appear. Same visibility as GET /wikis/{id}/sub_wikis, but that endpoint returns ONE level while this is the whole subtree.

                          **Capped at 200 nodes**, cut BREADTH-FIRST so what you receive is the top of the outline — a coherent partial hierarchy, never a dangling branch: a node is present only if its parent is, so `sub_wikis` can always be rendered as a tree as-is. Check `sub_wikis_truncated` before treating it as complete, and fetch the missing levels one at a time with GET /wikis/{id}/sub_wikis.
                        items:
                          "$ref": "#/components/schemas/WikiSubTreeNode"
                      sub_wikis_total:
                        type: integer
                        description: How many nodes the whole subtree holds — every
                          published, caller-visible descendant, counted before the
                          200-node cap. Equal to the number of nodes in `sub_wikis`
                          when `sub_wikis_truncated` is false.
                        example: 6
                      sub_wikis_truncated:
                        type: boolean
                        description: True when the cap cut the tree, i.e. `sub_wikis`
                          holds fewer than `sub_wikis_total` nodes. **Additive** —
                          a client that never reads it behaves exactly as before.
                        example: false
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller — missing, restricted,
            another user's draft, or another tenant's (error code `not_found`).
    delete:
      tags:
      - Wikis
      summary: Delete a wiki (soft-delete → Trash)
      description: |
        Soft-deletes a wiki — the native mirror of the web reader's delete action
        (`Apps::Wikis::PagesController#destroy` → `Wiki#discard!`). The page AND
        its entire sub-tree are moved to Trash in one transaction: they leave every
        list/read immediately but stay recoverable from web Trash. This is NOT a
        permanent delete.

        * **Authorization — only users who may MANAGE the wiki** (the web
          `can_manage_wiki?` gate): a business admin-or-above OR the wiki's
          creator. A member who can merely VIEW the page gets `403`; a page the
          caller cannot see at all returns `404`, never revealing it exists.
        * `sub_wikis_deleted` reports how many descendant pages were trashed with
          it (the cascade).
        * Deleting an already-trashed page returns `404` (it has left the default
          scope), so the call is safe to retry.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki moved to trash
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - deleted
                - sub_wikis_deleted
                - message
                properties:
                  wiki_id:
                    type: integer
                    description: The id of the deleted wiki.
                    example: 2633
                  deleted:
                    type: boolean
                    description: Always true on a 200 (the wiki was moved to Trash).
                    example: true
                  sub_wikis_deleted:
                    type: integer
                    description: How many descendant sub-pages were trashed with it
                      (cascade).
                    example: 2
                  message:
                    type: string
                    example: Wiki moved to trash.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller may view the wiki but is neither an admin-or-above
            nor its creator, OR the Wikis app is not accessible to them (error code
            `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller — missing, restricted,
            another user's draft, another tenant's, or already trashed (error code
            `not_found`).
  "/alerts":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: List the caller's received alerts (+ pending approvals)
      description: |
        Mirrors the broadcasts list API. Returns the caller's own alert inbox —
        alerts that were SENT to them (they have an `AlertDelivery` row), in
        sent/completed status — plus a `pending_approvals` array of alerts
        awaiting the caller's approval.

        Ordering mirrors the web Dashboard "Emergency Alerts" tab — most recent first (created_at DESC). Supports the subfilters via
        `filter`.

        `meta.segment_counts` is NOT the size of each tab's list. It counts
        what still NEEDS THE CALLER'S RESPONSE — the red-badge semantics — and
        is independent of the active `filter`. Two of the four therefore differ
        from their tab by design: `acknowledge` counts unacknowledged rows
        while its tab lists every ack-required alert (deliberately decoupled,
        ISS-20260713-662), and `all` counts alerts still owing a response while
        its list is the whole inbox, which additionally includes alerts the
        caller AUTHORED. `safety_check_in` and `draft` do match their tabs.
        Measured on a live tenant: all 24 vs 16, acknowledge 11 vs 10,
        safety_check_in 7 vs 7, draft 0 vs 0. Render tab badges from these
        counts only if you want "needs my response", not "how many rows".
      parameters:
      - name: filter
        in: query
        description: |
          Subfilter. Default `all`.
            * `all`             — every received alert (most recent first)
            * `acknowledge`     — `ack_required = true`
            * `safety_check_in` — `safety_check_in_required = true`
                                  (also accepts `safety` / `safety check in`)
            * `draft`           — the CALLER's OWN unsent alerts (authored +
                                  status 'draft'): plain drafts, ones awaiting
                                  approval, approved-pending-send, and rejected.
                                  These `alerts` items additionally carry
                                  `approval_state` + `approval_id` (and, when
                                  approved/rejected, `approval_decided_by`,
                                  `approval_decided_at`, `approval_notes`).
          (No `urgent` filter — the accountability invariant makes every alert
          urgent, so it would be identical to `all`.)
        required: false
        schema:
          type: string
          enum:
          - all
          - acknowledge
          - safety_check_in
          - draft
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Alerts listed (received inbox, most recent first)
          content:
            application/json:
              schema:
                type: object
                required:
                - alerts
                - pending_approvals
                - can_manage
                - meta
                properties:
                  alerts:
                    type: array
                    description: |
                      The filtered alert list. For `?filter=draft` these are the
                      caller's OWN unsent alerts and each item additionally carries
                      `approval_state` (draft | pending_approval | approved | rejected)
                      + `approval_id` (the latest approval request id, null for a
                      plain draft). Approved/rejected drafts also carry
                      `approval_decided_by` (reviewer name), `approval_decided_at`
                      (ISO-8601), and `approval_notes`. Other filters omit these fields.
                    items:
                      "$ref": "#/components/schemas/AlertSummary"
                  pending_approvals:
                    type: array
                    description: |
                      Alerts awaiting the CALLER's approval (pending Comms Hub
                      approval requests whose current step targets the caller's
                      role; admins see all). Same item shape as `alerts`.
                      Independent of pagination/?filter=.
                    items:
                      "$ref": "#/components/schemas/AlertSummary"
                  can_manage:
                    type: boolean
                    description: |
                      Root-level capability flag (not per item): whether the caller
                      can manage emergency alerts (send / cancel / view tracking) —
                      manager+, mirroring the web authorize_alert_access gate.
                  meta:
                    "$ref": "#/components/schemas/AlertListMeta"
        '401':
          description: Authentication required
        '403':
          description: The Broadcasts & Alerts app is not enabled for this business
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Create an alert — save as draft or publish (manager+)
      description: |
        Create an emergency alert. The client chooses the disposition via
        **`alert.status`**:
        - `"publish"` — the alert is **dispatched immediately** (status
          `sending`, the channel fan-out is enqueued), mirroring the web
          composer's **Send Now** — or routed through approval (see below).
        - **anything else** — `"draft"`, an **absent** status, or any
          unrecognized value — the alert is **saved as a draft only**: NOT routed
          through approval and NOT dispatched to recipients. Send it later via
          `POST /alerts/{id}/send_now`, or discard it via `DELETE /alerts/{id}`.
          The response carries `status: "draft"`.

        **Fail-safe default:** an alert is only ever sent when `alert.status` is
        explicitly `"publish"`; every other case is a draft.

        Requires **manager or above** (the web `authorize_alert_access` gate for
        emergency alerts).

        **Approval (publish only):** if an ENFORCED approval workflow governs
        emergency alerts, a non-admin's *publish* is routed through approval
        instead of dispatching — the alert is submitted for review and the
        response returns `status: "pending_approval"` (with `alert.status:
        "draft"`); an approver dispatches it later. Admins override approval and
        dispatch directly (same as the web Send-Now). A `"draft"` is never
        submitted for approval.

        **Targeting** is optional: supply any of `audience_id`,
        `notification_recipient_group_ids`, `alert.extra_user_ids`, or
        `alert.audience_criteria`. When none is supplied the alert targets
        EVERYONE in the business (the model's fallback), matching the web composer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - alert
              properties:
                alert:
                  type: object
                  required:
                  - title
                  - body
                  properties:
                    title:
                      type: string
                    body:
                      type: string
                    status:
                      type: string
                      enum:
                      - draft
                      - publish
                      description: |
                        Disposition of the new alert. `"publish"` runs the send
                        flow (immediate dispatch, or approval when an enforced
                        workflow governs it). Every other value — `"draft"`, an
                        ABSENT status, or anything unrecognized — saves it as a
                        draft only (NOT routed through approval, NOT dispatched;
                        send later via send_now, or discard via DELETE). Fail-safe:
                        only an explicit `"publish"` ever sends.
                    sms_body:
                      type: string
                    action_url:
                      type: string
                    urgent:
                      type: boolean
                    ack_required:
                      type: boolean
                    safety_check_in_required:
                      type: boolean
                    audience_id:
                      type: integer
                      nullable: true
                      description: A saved CommsHub audience to send to.
                    channels:
                      type: array
                      items:
                        type: string
                    extra_user_ids:
                      type: array
                      description: Specific user ids to send to (stored as the alert's
                        manual recipient list).
                      items:
                        type: integer
                    audience_criteria:
                      type: array
                      description: |
                        Attribute-based audience filters, each a typed hash — e.g.
                        `{ "type": "role", "roles": ["member"] }`,
                        `{ "type": "job_title", "titles": ["Area Manager"] }`,
                        `{ "type": "department", "ids": [1,2] }`,
                        `{ "type": "location", "ids": [3] }`. Validated + business-
                        scoped server-side.
                      items:
                        type: object
                        additionalProperties: true
                    media_signed_ids:
                      type: array
                      description: |
                        Attachments. Pre-upload each file via
                        `POST /rails/active_storage/direct_uploads` (standard
                        ActiveStorage direct upload) and pass the resulting blob
                        `signed_id`s here. They are attached to the alert's
                        `media_files` (Drive) — the same attachments the web
                        composer and the show API expose. Validated server-side
                        against the alert media rules (photos/video only, per-file
                        size cap, max 4 files); any invalid reference fails the
                        whole create with 422 and nothing is persisted.
                      items:
                        type: string
                notification_recipient_group_ids:
                  type: array
                  description: |
                    Notification recipient group ids to send to (top-level, not
                    under `alert`). Business-scoped server-side.
                  items:
                    type: integer
      responses:
        '201':
          description: |
            Alert created. When `alert.status: "draft"` the alert is **saved as a
            draft** (`status: "draft"`) — not dispatched, not sent for approval.
            Otherwise it is **published**: normally **dispatched** immediately
            (`status: "sending"`); or, if an enforced approval workflow governs
            emergency alerts and the caller is NOT an admin (admins override), it
            is **submitted for approval** — the response carries
            `status: "pending_approval"` and `alert.status: "draft"`; an approver
            dispatches it later via `POST /approvals/{id}/approve`.
          content:
            application/json:
              schema:
                type: object
                required:
                - alert
                - status
                properties:
                  alert:
                    "$ref": "#/components/schemas/AlertSummary"
                  attachments:
                    type: array
                    description: The attachments that were attached to the alert (from
                      media_signed_ids).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                  status:
                    type: string
                    enum:
                    - draft
                    - sending
                    - pending_approval
                    description: |
                      `draft` (saved only), `sending` (dispatched), or
                      `pending_approval` (routed to approval).
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Manager access required, or app not enabled
        '422':
          description: Validation failed, or approval was required but could not be
            submitted (`approval_required`)
  "/alerts/my":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: The caller's personal received-alert inbox
      description: |
        The API twin of the web My Alerts page (`/apps/safety-hub/alerts/my`): a
        PURE recipient inbox. Returns ONLY alerts that were SENT to the caller
        (they hold an `AlertDelivery` row), in a delivered state
        (sending/sent/completed/cancelled), most recent first.

        Deliberately different from `GET /alerts` (the mobile list): this endpoint
        has NO drafts, never surfaces alerts the caller AUTHORED but was not a
        recipient of, and carries NO manager envelope — there is no
        `pending_approvals`, `can_manage`, or `can_dispatch`. Open to every member,
        exactly like the web page.

        `meta.segment_counts` counts what still NEEDS THE CALLER'S RESPONSE
        (all / acknowledge / safety_check_in) over the received inbox, independent
        of the active `filter`. There is no `draft` segment.
      parameters:
      - name: filter
        in: query
        description: |
          Subfilter. Default `all`.
            * `all`             — every received alert (most recent first)
            * `acknowledge`     — `ack_required = true`
            * `safety_check_in` — `safety_check_in_required = true`
                                  (also accepts `safety` / `safety check in`)
          `draft` is not valid here (this feed has no drafts) and is coerced to
          `all`; the applied value is echoed as `meta.applied_filter`.
        required: false
        schema:
          type: string
          enum:
          - all
          - acknowledge
          - safety_check_in
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: The caller's received alerts, most recent first
          content:
            application/json:
              schema:
                type: object
                required:
                - alerts
                - meta
                properties:
                  alerts:
                    type: array
                    description: Alerts sent to the caller, each with the caller's
                      own response state.
                    items:
                      "$ref": "#/components/schemas/AlertSummary"
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - applied_filter
                    - segment_counts
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      applied_filter:
                        type: string
                        enum:
                        - all
                        - acknowledge
                        - safety_check_in
                        description: The filter actually applied (a `draft` request
                          is coerced to `all`).
                      segment_counts:
                        type: object
                        description: '"Needs my response" counts over the received
                          inbox, independent of `filter`. No `draft` segment (this
                          feed has no drafts).

                          '
                        required:
                        - all
                        - acknowledge
                        - safety_check_in
                        properties:
                          all:
                            type: integer
                          acknowledge:
                            type: integer
                          safety_check_in:
                            type: integer
        '401':
          description: Authentication required
        '403':
          description: Missing the read:broadcasts scope, or the app/module is not
            enabled
  "/alerts/team":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Team Alerts — the manager register of every alert in the business
      description: |
        The API twin of the web Team Alerts page (`/apps/safety-hub/alerts`): the
        manager/admin ops register of EVERY emergency alert in the business.

        Deliberately different from `GET /alerts` and `GET /alerts/my`, which are
        the caller's RECEIVED inbox. This is the tenant-wide management list, so
        it is MANAGER+ gated (a `manager_or_above?` user or the Safety Hub app
        admin — the same tier as the web `authorize_alert_access`) and carries the
        read token ceiling: a `read:own_broadcasts`-only token is refused (403).

        Every alert in the business, most recent first, filterable by lifecycle
        `status` and title/body `q`, paginated. Each row carries the standard
        alert shape plus a `response_summary` (delivery progress), and `meta`
        carries `status_counts` (per-tab totals) and the root-level `can_manage` /
        `can_dispatch` capability flags.
      parameters:
      - name: status
        in: query
        description: |
          Filter by lifecycle status. Default `all` (no filter).
            * `all`       — every status
            * `draft`     — composing, not yet sent
            * `sending`   — dispatch in progress (a transient state)
            * `sent`      — all per-recipient jobs enqueued
            * `completed` — every delivery terminal
            * `cancelled` — aborted
          An unrecognized value is treated as `all`; the applied value is echoed
          as `meta.applied_status`.
        required: false
        schema:
          type: string
          enum:
          - all
          - draft
          - sending
          - sent
          - completed
          - cancelled
          default: all
      - name: q
        in: query
        description: Title/body search (tokenized; matches on word, not order).
        required: false
        schema:
          type: string
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Every alert in the business, most recent first
          content:
            application/json:
              schema:
                type: object
                required:
                - alerts
                - can_manage
                - can_dispatch
                - meta
                properties:
                  alerts:
                    type: array
                    description: Every alert in the business (for this page), each
                      with a delivery-progress summary.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/AlertSummary"
                      - type: object
                        properties:
                          response_summary:
                            type: object
                            description: 'Delivery progress for the alert. `responded`
                              / `outstanding` are response-mode aware: for a safety-check-in
                              alert `responded` = safe + needs_help; for an acknowledge
                              alert `responded` = acknowledged; for a plain urgent
                              alert both are null (no response is owed).

                              '
                            properties:
                              recipients:
                                type: integer
                              acknowledged:
                                type: integer
                              safe:
                                type: integer
                              needs_help:
                                type: integer
                              responded:
                                type: integer
                                nullable: true
                              outstanding:
                                type: integer
                                nullable: true
                  can_manage:
                    type: boolean
                    description: Whether the caller can manage emergency alerts (compose/stage/cancel/tracking).
                  can_dispatch:
                    type: boolean
                    description: Whether the caller may DISPATCH an alert (the narrower
                      per-tenant policy).
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - applied_status
                    - status_counts
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      applied_status:
                        type: string
                        enum:
                        - all
                        - draft
                        - sending
                        - sent
                        - completed
                        - cancelled
                        description: The status actually applied (an unrecognized
                          request is coerced to `all`).
                      status_counts:
                        type: object
                        description: 'Per-tab totals over the whole business + search
                          base, INDEPENDENT of the active `status` filter, so a filter-row
                          badge always shows its true total. `all` is the sum.

                          '
                        required:
                        - all
                        - draft
                        - sending
                        - sent
                        - completed
                        - cancelled
                        properties:
                          all:
                            type: integer
                          draft:
                            type: integer
                          sending:
                            type: integer
                          sent:
                            type: integer
                          completed:
                            type: integer
                          cancelled:
                            type: integer
        '401':
          description: Authentication required
        '403':
          description: Not a manager, or the token is scoped to own alerts (needs
            read:broadcasts), or the app/module is not enabled
  "/alerts/templates":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: List alert templates for the composer's "Start from a template" picker
      description: |
        Powers the alert composer's "Start from a template" picker (opened from
        the alert list `+`). Returns the SAME two groups the web composer offers:
          * `your_templates`   — this business's saved alert templates (ordered)
          * `common_scenarios` — the platform-curated common scenarios
                                 (system templates, shared across businesses),
                                 so an author can start from a pre-approved
                                 emergency scenario.
        Each entry carries the composition fields the client pre-fills the
        new-alert form with (title / body / sms_body / channels + the urgency /
        ack / safety toggles). Requires **manager or above** — the same gate as
        creating an alert (`authorize_alert_access` → `manager_or_above?`), so
        anyone who can create an alert can fetch the templates to start from.
      responses:
        '200':
          description: Templates listed
          content:
            application/json:
              schema:
                type: object
                required:
                - your_templates
                - common_scenarios
                - meta
                properties:
                  your_templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AlertTemplate"
                  common_scenarios:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AlertTemplate"
                  meta:
                    type: object
                    required:
                    - your_templates_count
                    - common_scenarios_count
                    properties:
                      your_templates_count:
                        type: integer
                      common_scenarios_count:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required, or the app is not enabled
  "/alerts/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Get an alert
      description: |
        Returns the full alert plus its file `attachments` (the alert's
        media_files — photos/videos uploaded with the alert, the same Drive
        attachments the web alert detail renders).

        For a DRAFT (unpublished) alert, also carries the approval-pipeline
        fields (`approval_state`, `approval_id`, `approval_decided_by`,
        `approval_decided_at`, `approval_notes`) — the same shape the
        `?filter=draft` list returns per item. These reflect the alert's latest
        Comms Hub approval request. They are `null` once the alert is published
        (sent/scheduled), for a draft that never entered the approval pipeline,
        or for a caller who may not see the alert's internal review trail (a
        non-author, non-privileged viewer — the same gate as `can_view_tracking`).
      responses:
        '200':
          description: Alert found
          content:
            application/json:
              schema:
                type: object
                required:
                - alert
                properties:
                  alert:
                    allOf:
                    - "$ref": "#/components/schemas/AlertSummary"
                    - type: object
                      properties:
                        acknowledged:
                          type: boolean
                          description: 'Whether the calling user has acknowledged
                            this alert. Forced to true when the caller is the alert''s
                            author and is NOT in its audience (no delivery / not a
                            recipient) — they have nothing to acknowledge, so the
                            detail screen doesn''t strand an unacknowledged state
                            they can''t clear.

                            '
                        can_manage:
                          type: boolean
                          description: 'True if and only if the caller may view THIS
                            alert''s per-recipient tracking details (acknowledgement
                            / check-in rosters, recipient PII) — the alert''s author,
                            a broadcast app-admin, or an admin+. Matches the gate
                            on GET /alerts/{id}/acknowledgements, /check_ins, and
                            /recipients, so the client can show the "Tracking Details"
                            affordance only when those calls will succeed.

                            '
                        attachments:
                          type: array
                          description: The alert's file attachments (DriveItem media).
                          items:
                            type: object
                            required:
                            - id
                            - filename
                            properties:
                              id:
                                type: integer
                              filename:
                                type: string
                              content_type:
                                type: string
                                nullable: true
                              byte_size:
                                type: integer
                              url:
                                type: string
                                description: ActiveStorage blob URL (attachment disposition).
        '401':
          description: Authentication required
        '404':
          description: Alert not found
    patch:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Edit a draft alert (manager+)
      description: |
        Edit a **DRAFT** alert. The body is the **same shape as `POST /alerts`**
        (the `alert[...]` fields + `notification_recipient_group_ids` +
        `alert[media_signed_ids]` + the `alert[status]` disposition). PATCH
        semantics: only the fields you send are changed — an omitted field is
        left as-is, and audience targeting is replaced only when you send
        targeting params. `alert[media_signed_ids]` are ADDED to the draft
        (already-attached media is untouched).

        Like create, `alert[status]` chooses the disposition: `"publish"` sends
        the alert now (dispatch, or approval when an enforced workflow governs a
        non-admin) — the response then carries `status: "sending"` /
        `"pending_approval"`; any other value (incl. absent) keeps it a
        **draft**. So a client can edit-and-publish in one call.

        Constraints (mirror the web AlertsController#update): only a **draft** is
        editable (`422 not_a_draft` otherwise), and a draft with a **pending
        approval request** must have it withdrawn first (`422 approval_pending`).
        Requires **manager or above**.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - alert
              properties:
                alert:
                  type: object
                  description: |
                    Same permitted fields as `POST /alerts`. All are optional on
                    edit — send only what changes.
                  properties:
                    status:
                      type: string
                      enum:
                      - draft
                      - publish
                      description: Disposition — `"publish"` sends; anything else
                        keeps it a draft.
                    title:
                      type: string
                    body:
                      type: string
                    sms_body:
                      type: string
                    action_url:
                      type: string
                    urgent:
                      type: boolean
                    ack_required:
                      type: boolean
                    safety_check_in_required:
                      type: boolean
                    audience_id:
                      type: integer
                      nullable: true
                    channels:
                      type: array
                      items:
                        type: string
                    extra_user_ids:
                      type: array
                      items:
                        type: integer
                      description: Specific user ids (replaces the draft's manual
                        recipient list when sent).
                    audience_criteria:
                      type: array
                      description: Attribute-based audience filters (same typed hashes
                        as create).
                      items:
                        type: object
                        additionalProperties: true
                    media_signed_ids:
                      type: array
                      items:
                        type: string
                      description: Newly pre-uploaded ActiveStorage blob signed_ids
                        to ADD to the draft.
                notification_recipient_group_ids:
                  type: array
                  items:
                    type: integer
                  description: Notification recipient group ids (top-level; replaces
                    the draft's groups when sent).
      responses:
        '200':
          description: |
            Draft edited. `status: "draft"` when kept as a draft; `"sending"` when
            published (dispatched); `"pending_approval"` when published into an
            enforced approval workflow (with `alert.status: "draft"`).
          content:
            application/json:
              schema:
                type: object
                required:
                - alert
                - status
                properties:
                  alert:
                    "$ref": "#/components/schemas/AlertSummary"
                  attachments:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                  status:
                    type: string
                    enum:
                    - draft
                    - sending
                    - pending_approval
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Manager access required to manage emergency alerts
        '404':
          description: Alert not found
        '422':
          description: |
            The alert is not a draft (`not_a_draft`), has a pending approval
            request that must be withdrawn first (`approval_pending`), failed
            validation (`validation_failed`), had an invalid attachment
            (`invalid_attachments`), or couldn't be dispatched (`dispatch_failed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - not_a_draft
                        - approval_pending
                        - validation_failed
                        - invalid_attachments
                        - approval_required
                        - dispatch_failed
    delete:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Discard a draft alert (manager+)
      description: |
        Permanently deletes a DRAFT alert — mirrors the web "Discard draft"
        action (AlertsController#destroy). Only a draft can be discarded: once an
        alert has been sent (or is sending/cancelled) it is part of the delivery
        record and is retained. A draft awaiting approval must have its approval
        request withdrawn first, so a pending CommsHub::ApprovalRequest is never
        orphaned. Manager+ gated (same tier as create).
      responses:
        '200':
          description: Draft discarded
          content:
            application/json:
              schema:
                type: object
                required:
                - alert_id
                - status
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    example: discarded
                  message:
                    type: string
                    example: Draft discarded.
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
        '422':
          description: 'The alert is not a draft (error code `not_a_draft`), is awaiting
            approval and must be withdrawn first (`approval_pending`), or could not
            be discarded (`discard_failed`).

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - not_a_draft
                        - approval_pending
                        - discard_failed
                      message:
                        type: string
  "/alerts/{id}/send_now":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Dispatch a draft alert now (manager+)
      description: 'Flips a draft alert to `sending` and enqueues the dispatch fan-out.
        Manager-and-above only. When an enforced approval workflow governs emergency
        alerts, a non-admin (manager) can dispatch ONLY once the alert''s approval
        request is `approved` — pending, rejected, withdrawn, and never-submitted
        alerts are blocked with `422 approval_required`. Admins always bypass the
        workflow. A non-enforced or absent workflow never blocks.

        '
      responses:
        '200':
          description: Alert enqueued
        '403':
          description: Manager access required, or the alert is already dispatched
        '404':
          description: Alert not found
        '422':
          description: 'Approval is required before this alert can be dispatched (error
            code `approval_required`) — the alert is pending, rejected, or was never
            submitted under an enforced workflow.

            '
  "/alerts/{id}/cancel":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Cancel a draft/sending alert (admin only)
      responses:
        '200':
          description: Alert cancelled
        '403':
          description: Admin access required, or alert cannot be cancelled
        '404':
          description: Alert not found
  "/alerts/{id}/submit_for_approval":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Submit a draft alert for approval (manager+)
      description: 'Submits a DRAFT alert for review against the governing enforced
        approval workflow for emergency alerts — the API mirror of the web AlertsController#submit_for_approval,
        reusing the shared CommsHub::ApprovalEnforceable#submit_source_for_approval.
        Manager+ (same gate as create). The inverse of withdraw_approval. Reviewers
        are notified.

        '
      responses:
        '200':
          description: Alert submitted for approval (a pending request now exists;
            the alert stays draft)
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    description: Always "pending_approval" on success.
                  message:
                    type: string
        '403':
          description: Manager access required (or the Broadcasts & Alerts app is
            not enabled)
        '404':
          description: Alert not found
        '422':
          description: 'Cannot submit — the alert is not a draft (not_a_draft), no
            workflow governs emergency alerts (no_workflow), a request is already
            pending (already_pending), or the submit otherwise failed (submit_failed
            / no_item).

            '
  "/alerts/{id}/withdraw_approval":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Withdraw a pending approval request (manager+)
      description: 'Pulls back the alert''s pending Comms Hub approval request so
        the author can edit and resubmit — mirrors the web AlertsController#withdraw_approval.
        Manager+ at the controller (same gate as create); the model additionally allows
        only the ORIGINAL REQUESTER or an admin to actually withdraw, so a manager
        who did not submit it (and is not an admin) gets 422.

        '
      responses:
        '200':
          description: Approval request withdrawn (the alert returns to draft, editable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    description: The alert's status after withdrawal (draft).
                  message:
                    type: string
        '403':
          description: Manager access required (or the Broadcasts & Alerts app is
            not enabled)
        '404':
          description: Alert not found, or there is no pending approval request to
            withdraw
        '422':
          description: Could not withdraw (e.g. the caller is neither the original
            requester nor an admin)
  "/alerts/{id}/check_in":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Record a safety check-in response (recipient)
      description: |
        Records the caller's safety check-in response, mirroring the web
        AlertsController#check_in_submit. The caller must be a **recipient** of
        the alert (have an `AlertDelivery` row) — otherwise `403`.

        Idempotent upsert: a re-submit **updates** the caller's existing response,
        so a recipient can change it (e.g. from `needs_help` to `safe`). The
        `note` (situation details) is captured on the `needs_help` path and
        **cleared** when the recipient updates back to `safe`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - safe
                  - needs_help
                note:
                  type: string
                  nullable: true
                  description: |
                    Optional situation details. Stored only when `status` is
                    `needs_help`; ignored / cleared when `status` is `safe`.
      responses:
        '200':
          description: Check-in recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - alert_id
                - status
                - message
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - safe
                    - needs_help
                  note:
                    type: string
                    nullable: true
                    description: The stored situation note (null for a safe response).
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Not a recipient of this alert
        '404':
          description: Alert not found
        '422':
          description: Invalid status
  "/alerts/{id}/acknowledge":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Acknowledge an ack-required alert (recipient)
      description: |
        Records the caller's explicit read-confirmation for an acknowledge-type
        alert, mirroring the web AlertsController#acknowledge. **Recipient-gated**
        — the caller must be a recipient of the alert (have an `AlertDelivery`
        row); otherwise `403`. **Idempotent**: re-acknowledging is a no-op success
        that preserves the original `acknowledged_at`.
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                required:
                - alert_id
                - acknowledged
                properties:
                  alert_id:
                    type: integer
                  acknowledged:
                    type: boolean
                  acknowledged_at:
                    type: string
                    format: date-time
                    nullable: true
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Not a recipient of this alert (`not_a_recipient`)
        '404':
          description: Alert not found
  "/alerts/{id}/check_ins":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Safety check-in tracking roster (Tracking Details)
      description: |
        Powers the web **Tracking Details** screen for a safety-check-in alert:
        the alert's recipients (everyone it was delivered to) with their safety
        response, plus the safe / needs-help / unresponded counts.

        **Manager or above** — mirrors the web alert tracking page authorization
        (`authorize_alert_access` → `manager_or_above?`), same as the other
        tracking rosters. Only meaningful for an alert with
        `safety_check_in_required = true` (others return `422`).

        Each row carries the responder's `status` (`safe` / `needs_help` /
        `unresponded`), `responded_at`, and `note` (the situation details a
        "needs help" responder left). The recipient set is scoped to the
        business's users; rows are ordered by responder name (case-insensitive,
        with user id as a unique tiebreaker so paging cannot repeat or skip a
        recipient) — 20/page by default. Use `status` for the prioritized tabs.
      parameters:
      - name: status
        in: query
        description: |
          Roster filter. Default `all`.
            * `all`         — every recipient, each annotated with their status
            * `safe`        — recipients who marked themselves safe
            * `needs_help`  — recipients who responded "needs help"
            * `unresponded` — recipients who have not responded yet
        required: false
        schema:
          type: string
          enum:
          - all
          - safe
          - needs_help
          - unresponded
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed with their safety response, plus counts
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - status
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (profile photo variant,
                            else a ui-avatars fallback).
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        status:
                          type: string
                          enum:
                          - safe
                          - needs_help
                          - unresponded
                        responded_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the recipient responded (null when unresponded).
                        note:
                          type: string
                          nullable: true
                          description: Situation details left by a "needs help" responder
                            (null otherwise).
                  meta:
                    type: object
                    required:
                    - status
                    - total_recipients_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      status:
                        type: string
                        description: The resolved roster filter (all | safe | needs_help
                          | unresponded).
                      total_recipients_count:
                        type: integer
                        description: All recipients the alert was delivered to.
                      safe_count:
                        type: integer
                        description: Recipients who marked themselves safe. Included
                          only on the first page (page == 1).
                      need_help_count:
                        type: integer
                        description: Recipients who responded "needs help". Included
                          only on the first page (page == 1).
                      unresponded_count:
                        type: integer
                        description: Recipients who have not responded yet. Included
                          only on the first page (page == 1).
                      total_count:
                        type: integer
                        description: Items matching the active status filter (this
                          list's size).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
        '422':
          description: Alert does not require a safety check-in (`not_safety_check_in`)
  "/alerts/{id}/acknowledgements":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Acknowledgement tracking roster (Tracking Details)
      description: |
        Powers the web **Tracking Details** screen for an ACK-REQUIRED alert:
        the alert's recipients (everyone it was delivered to) filtered by whether
        they acknowledged, plus the ack / non-ack counts.

        **Manager or above** — mirrors the web alert tracking page authorization
        (`authorize_alert_access` → `manager_or_above?`). Only meaningful for an
        alert with `ack_required = true` (others return `422`).

        Each acked row carries the `acknowledged_at` date the screen shows
        (null on the `not_acked` tab). The recipient set is scoped to the
        business's users. The `acked` tab is ordered by acknowledgement time
        (newest first) with user id as a unique tiebreaker; the `not_acked` tab
        is ordered by user id. 20/page by default.
      parameters:
      - name: type
        in: query
        description: |
          Roster filter. Default `acked`.
            * `acked`     — recipients who HAVE acknowledged (with acknowledged_at)
            * `not_acked` — recipients who have NOT acknowledged
        required: false
        schema:
          type: string
          enum:
          - acked
          - not_acked
          default: acked
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed with acknowledgement state, plus counts
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (profile photo variant,
                            else a ui-avatars fallback).
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the recipient acknowledged (null on the
                            not_acked tab).
                  meta:
                    type: object
                    required:
                    - type
                    - total_recipients_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      type:
                        type: string
                        description: The resolved roster filter (acked | not_acked).
                      total_recipients_count:
                        type: integer
                        description: All recipients the alert was delivered to.
                      ack_count:
                        type: integer
                        description: Recipients who have acknowledged. Included only
                          on the first page (page == 1).
                      non_acked_count:
                        type: integer
                        description: Recipients who have not acknowledged. Included
                          only on the first page (page == 1).
                      total_count:
                        type: integer
                        description: Items matching the active type filter (this list's
                          size).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
        '422':
          description: Alert does not require acknowledgment (`not_ack_required`)
  "/alerts/{id}/recipients":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Alert delivery roster (Tracking Details — Recipients)
      description: |
        Powers the **Recipients** section of the web "Tracking Details" screen —
        the only tracking section present for a plain urgent alert (neither
        acknowledge- nor safety-check-in-required), and also available for the
        other alert types.

        Returns every recipient (everyone the alert was delivered to) with the
        **channels they were reached on** (in app / email / push / sms / voice,
        each with delivery status + time). **Manager+ only** (mirrors the web
        alerts#show authorize_alert_access gate).

        The recipient set is scoped to the business's users; rows are ordered by
        user id for stable pagination (20/page by default). `meta.channel_counts`
        is the per-channel reach (distinct recipients reached on each channel) —
        the per-channel summary the screen shows. It is a roster-wide aggregate
        and is returned **only on page 1** (the client renders it once in the
        header); it is omitted on subsequent pages.
      parameters:
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed with the channels they were reached on
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - channels
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (profile photo variant,
                            else a ui-avatars fallback).
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        email:
                          type: string
                          nullable: true
                          description: Recipient's email (row subtitle).
                        channels:
                          type: array
                          description: The channels this recipient was reached on.
                          items:
                            type: object
                            required:
                            - channel
                            properties:
                              channel:
                                type: string
                                description: Delivery channel (e.g. in_app, email,
                                  push, sms, voice).
                              status:
                                type: string
                                nullable: true
                                description: Per-channel delivery status (e.g. sent,
                                  failed, skipped).
                              delivered_at:
                                type: string
                                format: date-time
                                nullable: true
                  meta:
                    type: object
                    required:
                    - total_recipients_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_recipients_count:
                        type: integer
                        description: All recipients the alert was delivered to.
                      channel_counts:
                        type: object
                        description: |
                          Distinct recipients reached per channel, keyed by channel name.
                          Returned **only on page 1** (roster-wide aggregate); omitted on
                          subsequent pages.
                        additionalProperties:
                          type: integer
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
  "/tinytake/captures":
    get:
      tags:
      - TinyTake
      summary: List captures
      description: |
        Retrieve a paginated list of screen captures and video recordings.

        Use this endpoint to:
        - View my screen captures
        - List recorded videos
        - Get capture history
        - Find screenshots by tags
        - Browse my recordings
        - See all TinyTake files

        Supports filtering by type, tags, and date range with pagination.
      security:
      - BearerAuth: []
      parameters:
      - name: type
        in: query
        description: Filter by capture type
        schema:
          type: string
          enum:
          - image
          - video
          - all
          default: all
      - name: tags
        in: query
        description: Filter by tags (comma-separated)
        schema:
          type: string
        example: meeting,presentation
      - name: folder_id
        in: query
        description: Filter by folder
        schema:
          type: string
      - name: created_after
        in: query
        description: Filter captures created after this date
        schema:
          type: string
          format: date-time
      - name: created_before
        in: query
        description: Filter captures created before this date
        schema:
          type: string
          format: date-time
      - name: sort_by
        in: query
        description: Field to sort by
        schema:
          type: string
          enum:
          - created_at
          - updated_at
          - name
          - size
          default: created_at
      - name: sort_order
        in: query
        description: Sort direction
        schema:
          type: string
          enum:
          - asc
          - desc
          default: desc
      - name: page
        in: query
        description: Page number
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Results per page (max 100)
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      responses:
        '200':
          description: List of captures retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  count:
                    type: integer
                    example: 25
                  page:
                    type: integer
                    example: 1
                  per_page:
                    type: integer
                    example: 50
                  total_pages:
                    type: integer
                    example: 1
                  captures:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeCapture"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    post:
      tags:
      - TinyTake
      summary: Upload capture
      description: |
        Upload a new screen capture or video recording.

        Use this endpoint to:
        - Upload screenshot
        - Save screen recording
        - Upload video capture
        - Save image to TinyTake
        - Store screen capture

        Supports PNG, JPG, GIF images and MP4, WebM videos.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The image or video file to upload
                name:
                  type: string
                  description: Custom name for the capture (defaults to filename)
                  example: Meeting Notes 2024-01-15
                folder_id:
                  type: string
                  description: Target folder ID (defaults to root)
                tags:
                  type: string
                  description: Comma-separated tags
                  example: meeting,notes,q1
                visibility:
                  type: string
                  enum:
                  - private
                  - shared
                  - public
                  default: private
                  description: Capture visibility level
                generate_thumbnail:
                  type: boolean
                  default: true
                  description: Generate thumbnail for the capture
                transcode_video:
                  type: boolean
                  default: false
                  description: Transcode video for web playback
      responses:
        '201':
          description: Capture uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Capture uploaded successfully
                  capture:
                    "$ref": "#/components/schemas/TinyTakeCapture"
        '400':
          description: Invalid file or parameters
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '413':
          description: File too large
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '507':
          description: Insufficient storage quota
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/tinytake/captures/search":
    get:
      tags:
      - TinyTake
      summary: Search captures
      description: |
        Full-text search across captures with advanced filtering.

        Use this endpoint to:
        - Search captures by name
        - Find captures by content (OCR text)
        - Search by tags
        - Find captures by date range
        - Advanced search with multiple filters
      security:
      - BearerAuth: []
      parameters:
      - name: query
        in: query
        description: Search term (searches name, tags, OCR text)
        schema:
          type: string
        example: meeting notes
      - name: type
        in: query
        description: Filter by capture type
        schema:
          type: string
          enum:
          - image
          - video
          - all
      - name: tags
        in: query
        description: Filter by tags (comma-separated, AND logic)
        schema:
          type: string
        example: meeting,important
      - name: folder_id
        in: query
        description: Filter by folder
        schema:
          type: string
      - name: visibility
        in: query
        description: Filter by visibility
        schema:
          type: string
          enum:
          - private
          - shared
          - public
      - name: has_annotations
        in: query
        description: Filter by annotation presence
        schema:
          type: boolean
      - name: created_after
        in: query
        schema:
          type: string
          format: date-time
      - name: created_before
        in: query
        schema:
          type: string
          format: date-time
      - name: min_size
        in: query
        description: Minimum file size in bytes
        schema:
          type: integer
      - name: max_size
        in: query
        description: Maximum file size in bytes
        schema:
          type: integer
      - name: sort_by
        in: query
        schema:
          type: string
          enum:
          - relevance
          - created_at
          - updated_at
          - name
          - size
          default: relevance
      - name: sort_order
        in: query
        schema:
          type: string
          enum:
          - asc
          - desc
          default: desc
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 20
          maximum: 100
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  query:
                    type: string
                  count:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total_pages:
                    type: integer
                  captures:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/TinyTakeCapture"
                      - type: object
                        properties:
                          relevance_score:
                            type: number
                            description: Search relevance score (0-1)
                          matched_text:
                            type: string
                            description: Snippet of matched text with highlights
  "/tinytake/captures/recent":
    get:
      tags:
      - TinyTake
      summary: Get recent captures
      description: |
        Retrieve recently viewed or edited captures for quick access.

        Use this endpoint to:
        - Get recently viewed captures
        - Quick access to recent files
        - Continue where you left off
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        description: Number of recent captures to return
        schema:
          type: integer
          default: 10
          maximum: 50
      - name: activity_type
        in: query
        description: Type of recent activity
        schema:
          type: string
          enum:
          - viewed
          - edited
          - uploaded
          - shared
          - all
          default: all
      responses:
        '200':
          description: Recent captures
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  captures:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/TinyTakeCapture"
                      - type: object
                        properties:
                          last_activity:
                            type: string
                            enum:
                            - viewed
                            - edited
                            - uploaded
                            - shared
                          last_activity_at:
                            type: string
                            format: date-time
  "/tinytake/captures/bulk":
    post:
      tags:
      - TinyTake
      summary: Bulk operations on captures
      description: |
        Perform bulk operations on multiple captures at once.

        Use this endpoint to:
        - Move multiple captures to folder
        - Add/remove tags from multiple captures
        - Change visibility of multiple captures
        - Delete multiple captures

        Supports up to 100 captures per request.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - capture_ids
              - operation
              properties:
                capture_ids:
                  type: array
                  items:
                    type: string
                  minItems: 1
                  maxItems: 100
                  description: List of capture IDs to operate on
                operation:
                  type: string
                  enum:
                  - move
                  - add_tags
                  - remove_tags
                  - set_tags
                  - set_visibility
                  - delete
                  description: Operation to perform
                folder_id:
                  type: string
                  nullable: true
                  description: Target folder (for move operation, null for root)
                tags:
                  type: array
                  items:
                    type: string
                  description: Tags to add/remove/set
                visibility:
                  type: string
                  enum:
                  - private
                  - shared
                  - public
                  description: Target visibility (for set_visibility operation)
      responses:
        '200':
          description: Bulk operation completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Successfully updated 15 captures
                  processed_count:
                    type: integer
                    example: 15
                  failed_count:
                    type: integer
                    example: 0
                  failures:
                    type: array
                    items:
                      type: object
                      properties:
                        capture_id:
                          type: string
                        error:
                          type: string
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/tinytake/captures/search/text":
    get:
      tags:
      - TinyTake
      summary: Search by extracted text
      description: |
        Search captures by their extracted OCR text content.

        Use this endpoint to:
        - Find screenshots containing specific text
        - Search by visible text content
        - Full-text search in images
      security:
      - BearerAuth: []
      parameters:
      - name: query
        in: query
        required: true
        description: Text to search for
        schema:
          type: string
      - name: match_type
        in: query
        description: How to match the query
        schema:
          type: string
          enum:
          - contains
          - exact
          - fuzzy
          default: contains
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 20
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  query:
                    type: string
                  count:
                    type: integer
                  captures:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/TinyTakeCapture"
                      - type: object
                        properties:
                          matched_text:
                            type: string
                            description: Text snippet with match highlighted
                          match_count:
                            type: integer
                            description: Number of matches in this capture
  "/tinytake/captures/{id}":
    get:
      tags:
      - TinyTake
      summary: Get capture details
      description: |
        Retrieve detailed metadata for a specific capture.

        Use this endpoint to:
        - Get capture information
        - View file details
        - Check capture status
        - Get download link
        - View capture metadata
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Capture ID
        schema:
          type: string
      responses:
        '200':
          description: Capture details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  capture:
                    "$ref": "#/components/schemas/TinyTakeCaptureDetail"
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    patch:
      tags:
      - TinyTake
      summary: Update capture
      description: |
        Update capture metadata (name, tags, visibility, folder).

        Use this endpoint to:
        - Rename capture
        - Update tags
        - Change visibility
        - Move to folder
        - Edit capture details
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Capture ID
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: New name for the capture
                  example: Updated Meeting Notes
                tags:
                  type: array
                  items:
                    type: string
                  description: New tags (replaces existing)
                  example:
                  - meeting
                  - important
                  - q1
                visibility:
                  type: string
                  enum:
                  - private
                  - shared
                  - public
                  description: New visibility level
                folder_id:
                  type: string
                  nullable: true
                  description: Move to folder (null for root)
      responses:
        '200':
          description: Capture updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Capture updated successfully
                  capture:
                    "$ref": "#/components/schemas/TinyTakeCaptureDetail"
        '400':
          description: Invalid parameters
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - TinyTake
      summary: Delete capture
      description: |
        Permanently delete a capture.

        Use this endpoint to:
        - Delete screenshot
        - Remove recording
        - Delete capture
        - Remove file from TinyTake

        ⚠️ This action cannot be undone.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Capture ID
        schema:
          type: string
      responses:
        '200':
          description: Capture deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Capture deleted successfully
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/tinytake/captures/{id}/download":
    get:
      tags:
      - TinyTake
      summary: Download capture
      description: |
        Download the original capture file.

        Use this endpoint to:
        - Download screenshot
        - Download video recording
        - Get original file
        - Save capture locally
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Capture ID
        schema:
          type: string
      responses:
        '200':
          description: File content
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            image/png:
              schema:
                type: string
                format: binary
            image/jpeg:
              schema:
                type: string
                format: binary
            video/mp4:
              schema:
                type: string
                format: binary
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/tinytake/captures/{id}/share":
    post:
      tags:
      - TinyTake
      summary: Generate share link
      description: |
        Generate a shareable link for a capture.

        Use this endpoint to:
        - Share screenshot
        - Create share link
        - Generate public URL
        - Share recording with others
        - Get shareable link

        Supports expiration and optional password protection.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Capture ID
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_in:
                  type: integer
                  description: Link expiration in seconds (null for permanent)
                  example: 86400
                  nullable: true
                password:
                  type: string
                  description: Optional password protection
                  nullable: true
      responses:
        '200':
          description: Share link generated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  share_url:
                    type: string
                    format: uri
                    example: https://hub.mangoapps.com/tt/abc123
                  expires_at:
                    type: string
                    format: date-time
                    nullable: true
                  has_password:
                    type: boolean
                    example: false
        '404':
          description: Capture not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - TinyTake
      summary: Revoke share link
      description: |
        Revoke an existing share link for a capture.

        Use this endpoint to:
        - Stop sharing
        - Revoke access
        - Disable share link
        - Remove public access
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Capture ID
        schema:
          type: string
      responses:
        '200':
          description: Share link revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Share link revoked
        '404':
          description: Capture not found or no share link exists
  "/tinytake/captures/{id}/share/users":
    get:
      tags:
      - TinyTake
      summary: List users capture is shared with
      description: Get list of users and teams this capture is shared with.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Share list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  shares:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeShare"
        '404':
          description: Capture not found
    post:
      tags:
      - TinyTake
      summary: Share with users/teams
      description: |
        Share a capture with specific users or teams.

        Use this endpoint to:
        - Share with specific users
        - Share with team
        - Grant view/comment/edit access
        - Send notification to recipients
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - recipients
              properties:
                recipients:
                  type: array
                  items:
                    type: object
                    required:
                    - type
                    - id
                    properties:
                      type:
                        type: string
                        enum:
                        - user
                        - team
                      id:
                        type: string
                        description: User ID or Team ID
                permission:
                  type: string
                  enum:
                  - view
                  - comment
                  - edit
                  default: view
                notify:
                  type: boolean
                  default: true
                  description: Send notification to recipients
                message:
                  type: string
                  description: Optional message to include in notification
      responses:
        '200':
          description: Capture shared successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Shared with 3 users
                  shares:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeShare"
        '404':
          description: Capture not found
    delete:
      tags:
      - TinyTake
      summary: Revoke user/team access
      description: Remove sharing access from specific users or teams.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - recipients
              properties:
                recipients:
                  type: array
                  items:
                    type: object
                    required:
                    - type
                    - id
                    properties:
                      type:
                        type: string
                        enum:
                        - user
                        - team
                      id:
                        type: string
      responses:
        '200':
          description: Access revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Removed access for 2 users
  "/tinytake/captures/{id}/annotations":
    get:
      tags:
      - TinyTake
      summary: Get capture annotations
      description: |
        Retrieve all annotations on a capture (arrows, text, shapes, blur regions).

        Use this endpoint to:
        - Get annotations to display
        - Load annotations for editing
        - Check if capture has annotations
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Capture annotations
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  capture_id:
                    type: string
                  annotations:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeAnnotation"
                  canvas_width:
                    type: integer
                    description: Original canvas width for scaling
                  canvas_height:
                    type: integer
                    description: Original canvas height for scaling
        '404':
          description: Capture not found
    put:
      tags:
      - TinyTake
      summary: Save capture annotations
      description: |
        Save or update all annotations on a capture.

        Use this endpoint to:
        - Save annotation edits
        - Add new annotations
        - Update existing annotations
        - Remove annotations (by omitting them)

        Replaces all existing annotations with the provided set.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - annotations
              properties:
                annotations:
                  type: array
                  items:
                    "$ref": "#/components/schemas/TinyTakeAnnotationInput"
                canvas_width:
                  type: integer
                  description: Canvas width used when creating annotations
                canvas_height:
                  type: integer
                  description: Canvas height used when creating annotations
      responses:
        '200':
          description: Annotations saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Annotations saved successfully
                  annotation_count:
                    type: integer
        '400':
          description: Invalid annotations
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/flatten":
    post:
      tags:
      - TinyTake
      summary: Flatten annotations
      description: |
        Flatten annotations into the image, creating a new version.

        Use this endpoint to:
        - Burn annotations into image
        - Create shareable version with annotations
        - Export annotated image

        Original capture is preserved; creates a new capture with flattened annotations.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Name for the flattened capture (defaults to original
                    + "_annotated")
                replace_original:
                  type: boolean
                  default: false
                  description: Replace original instead of creating new capture
      responses:
        '201':
          description: Flattened capture created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  capture:
                    "$ref": "#/components/schemas/TinyTakeCaptureDetail"
        '400':
          description: Capture has no annotations
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/comments":
    get:
      tags:
      - TinyTake
      summary: Get capture comments
      description: |
        Retrieve all comments on a capture.

        Use this endpoint to:
        - View comments
        - Load comment thread
        - Get feedback on capture
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: sort_order
        in: query
        schema:
          type: string
          enum:
          - asc
          - desc
          default: asc
      responses:
        '200':
          description: Capture comments
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  capture_id:
                    type: string
                  comment_count:
                    type: integer
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeComment"
        '404':
          description: Capture not found
    post:
      tags:
      - TinyTake
      summary: Add comment to capture
      description: |
        Add a comment to a capture. Supports optional position anchoring for contextual comments.

        Use this endpoint to:
        - Add feedback
        - Comment on specific area
        - Reply to existing comment
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 2000
                  description: Comment text (supports markdown)
                parent_id:
                  type: string
                  nullable: true
                  description: Parent comment ID for replies
                position:
                  type: object
                  description: Optional position anchor for contextual comments
                  properties:
                    x:
                      type: number
                      description: X coordinate (percentage 0-100)
                    "y":
                      type: number
                      description: Y coordinate (percentage 0-100)
                    timestamp:
                      type: number
                      description: Video timestamp in seconds (for video comments)
      responses:
        '201':
          description: Comment added
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  comment:
                    "$ref": "#/components/schemas/TinyTakeComment"
        '400':
          description: Invalid comment
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/comments/{comment_id}":
    patch:
      tags:
      - TinyTake
      summary: Update comment
      description: Edit an existing comment (author only).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: comment_id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 2000
      responses:
        '200':
          description: Comment updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  comment:
                    "$ref": "#/components/schemas/TinyTakeComment"
        '403':
          description: Not authorized to edit this comment
        '404':
          description: Comment not found
    delete:
      tags:
      - TinyTake
      summary: Delete comment
      description: Delete a comment (author or capture owner only).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: comment_id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        '403':
          description: Not authorized to delete this comment
        '404':
          description: Comment not found
  "/tinytake/captures/{id}/stream":
    get:
      tags:
      - TinyTake
      summary: Get video stream URL
      description: |
        Get a streaming URL for video playback.

        Use this endpoint to:
        - Get video playback URL
        - Stream video in browser
        - Get adaptive streaming manifest
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: quality
        in: query
        description: Preferred quality (if available)
        schema:
          type: string
          enum:
          - auto
          - 360p
          - 480p
          - 720p
          - 1080p
          - original
          default: auto
      responses:
        '200':
          description: Streaming information
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  stream_url:
                    type: string
                    format: uri
                    description: Direct streaming URL
                  hls_url:
                    type: string
                    format: uri
                    nullable: true
                    description: HLS manifest URL (if available)
                  available_qualities:
                    type: array
                    items:
                      type: string
                  duration_seconds:
                    type: number
                  expires_at:
                    type: string
                    format: date-time
                    description: When the stream URL expires
        '400':
          description: Not a video capture
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/status":
    get:
      tags:
      - TinyTake
      summary: Get processing status
      description: |
        Get the processing/transcoding status of a capture.

        Use this endpoint to:
        - Check upload processing status
        - Monitor video transcoding
        - Check thumbnail generation
        - Poll for completion
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Processing status
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  capture_id:
                    type: string
                  status:
                    type: string
                    enum:
                    - uploading
                    - processing
                    - transcoding
                    - ready
                    - failed
                  progress:
                    type: integer
                    description: Progress percentage (0-100)
                  stages:
                    type: object
                    properties:
                      upload:
                        type: string
                        enum:
                        - pending
                        - in_progress
                        - completed
                        - failed
                      thumbnail:
                        type: string
                        enum:
                        - pending
                        - in_progress
                        - completed
                        - failed
                        - skipped
                      transcode:
                        type: string
                        enum:
                        - pending
                        - in_progress
                        - completed
                        - failed
                        - skipped
                      ocr:
                        type: string
                        enum:
                        - pending
                        - in_progress
                        - completed
                        - failed
                        - skipped
                  error:
                    type: string
                    nullable: true
                    description: Error message if status is failed
                  estimated_completion:
                    type: string
                    format: date-time
                    nullable: true
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/chapters":
    get:
      tags:
      - TinyTake
      summary: Get video chapters
      description: Get chapter markers for a video.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Video chapters
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  chapters:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeVideoChapter"
        '400':
          description: Not a video capture
        '404':
          description: Capture not found
    put:
      tags:
      - TinyTake
      summary: Set video chapters
      description: |
        Add or update chapter markers for a video.

        Use this endpoint to:
        - Add chapter navigation
        - Mark important sections
        - Create table of contents
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - chapters
              properties:
                chapters:
                  type: array
                  items:
                    type: object
                    required:
                    - timestamp
                    - title
                    properties:
                      timestamp:
                        type: number
                        description: Start time in seconds
                      title:
                        type: string
                        maxLength: 100
                      description:
                        type: string
                        maxLength: 500
      responses:
        '200':
          description: Chapters saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  chapters:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeVideoChapter"
        '400':
          description: Not a video or invalid chapters
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/trim":
    post:
      tags:
      - TinyTake
      summary: Trim video
      description: |
        Create a trimmed version of a video.

        Use this endpoint to:
        - Trim video start/end
        - Extract video clip
        - Remove unwanted sections

        Creates a new capture with the trimmed video.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - start_time
              - end_time
              properties:
                start_time:
                  type: number
                  description: Start timestamp in seconds
                  minimum: 0
                end_time:
                  type: number
                  description: End timestamp in seconds
                name:
                  type: string
                  description: Name for trimmed video (defaults to original + "_trimmed")
                replace_original:
                  type: boolean
                  default: false
      responses:
        '202':
          description: Trim job started
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Trim job started
                  job_id:
                    type: string
                  estimated_duration:
                    type: integer
                    description: Estimated processing time in seconds
        '400':
          description: Invalid time range or not a video
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/gif":
    post:
      tags:
      - TinyTake
      summary: Convert to GIF
      description: |
        Convert a video clip to an animated GIF.

        Use this endpoint to:
        - Create shareable GIF
        - Convert video to animated image
        - Make preview/thumbnail GIF

        Creates a new capture with the generated GIF.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                start_time:
                  type: number
                  description: Start timestamp (defaults to 0)
                  default: 0
                end_time:
                  type: number
                  description: End timestamp (defaults to video end, max 30 seconds)
                fps:
                  type: integer
                  description: Frames per second
                  default: 10
                  minimum: 5
                  maximum: 30
                width:
                  type: integer
                  description: Output width (height auto-calculated)
                  maximum: 800
                quality:
                  type: string
                  enum:
                  - low
                  - medium
                  - high
                  default: medium
                name:
                  type: string
                  description: Name for generated GIF
      responses:
        '202':
          description: GIF generation started
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: GIF generation started
                  job_id:
                    type: string
                  estimated_duration:
                    type: integer
        '400':
          description: Not a video or invalid parameters
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/extract-text":
    post:
      tags:
      - TinyTake
      summary: Extract text (OCR)
      description: |
        Extract text from a screenshot using OCR.

        Use this endpoint to:
        - Extract text from image
        - Make screenshots searchable
        - Copy text from screenshot

        Results are stored and used for search indexing.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                languages:
                  type: array
                  items:
                    type: string
                  description: Language hints for OCR (ISO 639-1 codes)
                  example:
                  - en
                  - es
                force:
                  type: boolean
                  default: false
                  description: Re-run OCR even if already processed
      responses:
        '200':
          description: Text extracted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  capture_id:
                    type: string
                  text:
                    type: string
                    description: Full extracted text
                  blocks:
                    type: array
                    items:
                      type: object
                      properties:
                        text:
                          type: string
                        confidence:
                          type: number
                        bounding_box:
                          type: object
                          properties:
                            x:
                              type: number
                            "y":
                              type: number
                            width:
                              type: number
                            height:
                              type: number
                  language_detected:
                    type: string
        '400':
          description: Not an image capture
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/describe":
    post:
      tags:
      - TinyTake
      summary: AI describe capture
      description: |
        Generate an AI description of the capture content.

        Use this endpoint to:
        - Generate alt-text
        - Create automatic description
        - Summarize screenshot content
        - Describe video content
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                style:
                  type: string
                  enum:
                  - brief
                  - detailed
                  - technical
                  - accessibility
                  default: brief
                  description: Description style
                save_as_description:
                  type: boolean
                  default: false
                  description: Save as capture description
      responses:
        '200':
          description: Description generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  description:
                    type: string
                  suggested_tags:
                    type: array
                    items:
                      type: string
                  detected_elements:
                    type: array
                    items:
                      type: string
                    description: UI elements, text, objects detected
        '404':
          description: Capture not found
  "/tinytake/captures/{id}/analytics":
    get:
      tags:
      - TinyTake
      summary: Get capture analytics
      description: |
        Get view and download statistics for a capture.

        Use this endpoint to:
        - See how many views
        - Track downloads
        - Monitor share engagement
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: period
        in: query
        description: Time period for analytics
        schema:
          type: string
          enum:
          - day
          - week
          - month
          - year
          - all
          default: month
      responses:
        '200':
          description: Capture analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  capture_id:
                    type: string
                  period:
                    type: string
                  totals:
                    type: object
                    properties:
                      views:
                        type: integer
                      unique_viewers:
                        type: integer
                      downloads:
                        type: integer
                      shares:
                        type: integer
                      comments:
                        type: integer
                  timeline:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        views:
                          type: integer
                        downloads:
                          type: integer
        '404':
          description: Capture not found
  "/tinytake/tags":
    get:
      tags:
      - TinyTake
      summary: List all tags
      description: |
        Retrieve all tags used across captures with usage counts.

        Use this endpoint to:
        - Get all available tags
        - See tag usage statistics
        - Build tag cloud/selector
      security:
      - BearerAuth: []
      parameters:
      - name: sort_by
        in: query
        description: Sort tags by
        schema:
          type: string
          enum:
          - name
          - count
          - recent
          default: count
      - name: limit
        in: query
        description: Maximum tags to return
        schema:
          type: integer
          default: 100
          maximum: 500
      responses:
        '200':
          description: List of tags
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  tags:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeTag"
  "/tinytake/shared":
    get:
      tags:
      - TinyTake
      summary: List captures shared with me
      description: |
        Retrieve captures that others have shared with you.

        Use this endpoint to:
        - View shared captures
        - See what's been shared with me
        - Access team shared content
      security:
      - BearerAuth: []
      parameters:
      - name: shared_by
        in: query
        description: Filter by user who shared
        schema:
          type: string
      - name: sort_by
        in: query
        schema:
          type: string
          enum:
          - shared_at
          - name
          - created_at
          default: shared_at
      - name: sort_order
        in: query
        schema:
          type: string
          enum:
          - asc
          - desc
          default: desc
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 20
      responses:
        '200':
          description: Shared captures
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                  captures:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/TinyTakeCapture"
                      - type: object
                        properties:
                          shared_by:
                            "$ref": "#/components/schemas/TinyTakeUser"
                          shared_at:
                            type: string
                            format: date-time
                          permission:
                            type: string
                            enum:
                            - view
                            - comment
                            - edit
  "/tinytake/activity":
    get:
      tags:
      - TinyTake
      summary: Get activity log
      description: |
        Get user's TinyTake activity history.

        Use this endpoint to:
        - View activity history
        - Audit trail
        - See recent actions
      security:
      - BearerAuth: []
      parameters:
      - name: action
        in: query
        description: Filter by action type
        schema:
          type: string
          enum:
          - upload
          - view
          - download
          - share
          - delete
          - edit
          - comment
      - name: capture_id
        in: query
        description: Filter by specific capture
        schema:
          type: string
      - name: after
        in: query
        schema:
          type: string
          format: date-time
      - name: before
        in: query
        schema:
          type: string
          format: date-time
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      responses:
        '200':
          description: Activity log
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                  activities:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeActivity"
  "/tinytake/stats":
    get:
      tags:
      - TinyTake
      summary: Get usage statistics
      description: |
        Get overall TinyTake usage statistics.

        Use this endpoint to:
        - View usage summary
        - Check capture statistics
        - Monitor trends
      security:
      - BearerAuth: []
      parameters:
      - name: period
        in: query
        description: Time period for stats
        schema:
          type: string
          enum:
          - week
          - month
          - quarter
          - year
          - all
          default: month
      responses:
        '200':
          description: Usage statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  period:
                    type: string
                  summary:
                    type: object
                    properties:
                      total_captures:
                        type: integer
                      total_images:
                        type: integer
                      total_videos:
                        type: integer
                      total_size_bytes:
                        type: integer
                        format: int64
                      captures_this_period:
                        type: integer
                      storage_used_percentage:
                        type: number
                  trends:
                    type: object
                    properties:
                      captures_trend:
                        type: number
                        description: Percentage change from previous period
                      storage_trend:
                        type: number
                  top_tags:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeTag"
                  by_type:
                    type: object
                    properties:
                      image:
                        type: integer
                      video:
                        type: integer
                  timeline:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          format: date
                        captures:
                          type: integer
                        size_bytes:
                          type: integer
                          format: int64
  "/tinytake/folders":
    get:
      tags:
      - TinyTake
      summary: List folders
      description: |
        Retrieve all folders for organizing captures.

        Use this endpoint to:
        - List my folders
        - Get folder structure
        - View folder hierarchy
        - Browse folders
      security:
      - BearerAuth: []
      parameters:
      - name: parent_id
        in: query
        description: Parent folder ID (null for root folders)
        schema:
          type: string
          nullable: true
      responses:
        '200':
          description: List of folders
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  folders:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TinyTakeFolder"
    post:
      tags:
      - TinyTake
      summary: Create folder
      description: |
        Create a new folder for organizing captures.

        Use this endpoint to:
        - Create new folder
        - Add folder
        - Organize captures
        - Create subfolder
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                  description: Folder name
                  example: Project Screenshots
                parent_id:
                  type: string
                  nullable: true
                  description: Parent folder ID (null for root)
      responses:
        '201':
          description: Folder created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Folder created successfully
                  folder:
                    "$ref": "#/components/schemas/TinyTakeFolder"
        '400':
          description: Invalid folder name
        '409':
          description: Folder with this name already exists
  "/tinytake/folders/{id}":
    patch:
      tags:
      - TinyTake
      summary: Update folder
      description: Rename or move a folder.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: New folder name
                parent_id:
                  type: string
                  nullable: true
                  description: New parent folder (null for root)
      responses:
        '200':
          description: Folder updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  folder:
                    "$ref": "#/components/schemas/TinyTakeFolder"
        '404':
          description: Folder not found
    delete:
      tags:
      - TinyTake
      summary: Delete folder
      description: 'Delete a folder. Captures in the folder are moved to root, not
        deleted.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Folder deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Folder deleted. 5 captures moved to root.
                  moved_captures_count:
                    type: integer
        '404':
          description: Folder not found
  "/tinytake/storage":
    get:
      tags:
      - TinyTake
      summary: Get storage info
      description: |
        Retrieve storage usage, limits, and quota information.

        Use this endpoint to:
        - Check storage usage
        - View storage limits
        - Check quota
        - See remaining storage
        - Get storage statistics
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Storage information
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  storage:
                    "$ref": "#/components/schemas/TinyTakeStorageInfo"
  "/tinytake/settings":
    get:
      tags:
      - TinyTake
      summary: Get recorder settings
      description: |
        Retrieve TinyTake client settings including hotkeys and preferences.

        Use this endpoint to:
        - Get hotkey configuration
        - View recorder settings
        - Check default preferences
        - Get client configuration
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Recorder settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  settings:
                    "$ref": "#/components/schemas/TinyTakeSettings"
    patch:
      tags:
      - TinyTake
      summary: Update recorder settings
      description: |
        Update TinyTake client preferences.

        Use this endpoint to:
        - Update hotkeys
        - Change default settings
        - Configure preferences
        - Set default format
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TinyTakeSettingsUpdate"
      responses:
        '200':
          description: Settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Settings updated successfully
                  settings:
                    "$ref": "#/components/schemas/TinyTakeSettings"
        '400':
          description: Invalid settings
  "/tinytake/version":
    get:
      tags:
      - TinyTake
      summary: Check for updates
      description: |
        Check if a newer version of TinyTake client is available.

        Use this endpoint to:
        - Check for updates
        - Get latest version info
        - Check if upgrade available
      security:
      - BearerAuth: []
      parameters:
      - name: current_version
        in: query
        required: true
        description: Current client version
        schema:
          type: string
        example: 18.2.40
      - name: platform
        in: query
        required: true
        description: Client platform
        schema:
          type: string
          enum:
          - windows
          - macos
          - linux
      - name: os_version
        in: query
        description: Operating system version
        schema:
          type: string
      responses:
        '200':
          description: Version check result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  update_available:
                    type: boolean
                    example: true
                  current_version:
                    type: string
                    example: 18.2.40
                  latest_version:
                    type: string
                    example: 18.3.0
                  release_notes:
                    type: string
                    nullable: true
                  download_url:
                    type: string
                    format: uri
                    nullable: true
                  is_mandatory:
                    type: boolean
                    example: false
                  published_at:
                    type: string
                    format: date-time
                    nullable: true
  "/tinytake/downloads":
    get:
      tags:
      - TinyTake
      summary: Get download URLs
      description: |
        Retrieve download URLs for TinyTake client installers.

        Use this endpoint to:
        - Get installer download link
        - Download TinyTake client
        - Get platform-specific installer
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Download URLs for all platforms
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  downloads:
                    type: object
                    properties:
                      windows:
                        type: object
                        properties:
                          installer:
                            type: string
                            format: uri
                          portable:
                            type: string
                            format: uri
                            nullable: true
                          version:
                            type: string
                      macos:
                        type: object
                        properties:
                          installer:
                            type: string
                            format: uri
                          version:
                            type: string
                      linux:
                        type: object
                        properties:
                          deb:
                            type: string
                            format: uri
                            nullable: true
                          rpm:
                            type: string
                            format: uri
                            nullable: true
                          appimage:
                            type: string
                            format: uri
                            nullable: true
                          version:
                            type: string
  "/tinytake/feedback":
    post:
      tags:
      - TinyTake
      summary: Submit feedback
      description: |
        Submit feedback or report a problem from TinyTake client.

        Use this endpoint to:
        - Report a bug
        - Submit feedback
        - Request feature
        - Report problem
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - type
              - message
              properties:
                type:
                  type: string
                  enum:
                  - bug
                  - feature_request
                  - question
                  - other
                  description: Type of feedback
                  example: bug
                message:
                  type: string
                  minLength: 10
                  maxLength: 5000
                  description: Feedback message
                  example: Screen recording stops unexpectedly after 5 minutes
                attachments:
                  type: array
                  items:
                    type: string
                  description: Capture IDs to attach as evidence
                  maxItems: 5
                system_info:
                  type: object
                  properties:
                    os_name:
                      type: string
                    os_version:
                      type: string
                    app_version:
                      type: string
                    device_id:
                      type: string
      responses:
        '201':
          description: Feedback submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Thank you for your feedback
                  feedback_id:
                    type: string
                    example: fb_abc123
        '400':
          description: Invalid feedback
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/compensation/profile":
    get:
      tags:
      - Employee Compensation
      summary: Get employee compensation profile
      description: |
        Retrieves the current employee's compensation profile including current compensation details,
        policies, and eligibility information. Respects organization compensation policies for employee self-view.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Employee compensation profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_profile:
                    type: object
                    description: Employee compensation profile with current details
                      and policies
                    properties:
                      id:
                        type: integer
                        description: User business ID
                        example: 123
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          title:
                            type: string
                            nullable: true
                            example: Software Engineer
                          hire_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2023-01-15'
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            enum:
                            - salary
                            - hourly
                            - commission
                            - contract
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example: 36.06
                          currency:
                            type: string
                            example: USD
                          pay_frequency:
                            type: string
                            enum:
                            - weekly
                            - biweekly
                            - monthly
                            - quarterly
                            - annually
                            example: monthly
                          effective_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-01'
                      policies:
                        type: object
                        properties:
                          can_view_details:
                            type: boolean
                            example: true
                          can_request_changes:
                            type: boolean
                            example: true
                          request_frequency:
                            type: string
                            enum:
                            - annual
                            - semi_annual
                            - unlimited
                            example: annual
                          mobile_access_enabled:
                            type: boolean
                            example: true
                      next_review_eligible:
                        type: string
                        format: date
                        nullable: true
                        example: '2024-12-01'
                      last_updated:
                        type: object
                        properties:
                          date:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:30:00Z'
                          by:
                            type: string
                            example: System
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/compensation/history":
    get:
      tags:
      - Employee Compensation
      summary: Get compensation change history
      description: |
        Retrieves the employee's compensation change history with pagination and filtering options.
        Shows historical compensation changes, reasons, and approval information.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: |
          Number of items per page. Values above 100 are clamped to 100;
          blank, non-numeric and non-positive values fall back to the default.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      - name: from_date
        in: query
        description: |
          Filter changes on or after this date (YYYY-MM-DD). Inclusive — the
          bound is midnight (UTC) at the start of the day.
        schema:
          type: string
          format: date
      - name: to_date
        in: query
        description: |
          Filter changes on or before this date (YYYY-MM-DD). Inclusive of the
          WHOLE day — the bound is 23:59:59.999999 (UTC).
          filter-search-audit 2026-09-02: the description already read as
          inclusive while the query bound was `change_date <= <midnight>`, which
          excluded every change recorded later that day (2,162 of 2,168 rows
          carry a non-midnight time). The scope was fixed to match this wording,
          not the other way round.
        schema:
          type: string
          format: date
      responses:
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '200':
          description: Compensation history retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Individual compensation change record
                      properties:
                        id:
                          type: integer
                          example: 789
                        change_date:
                          type: string
                          format: date-time
                          example: '2024-01-15T10:30:00Z'
                        effective_date:
                          type: string
                          format: date
                          example: '2024-02-01'
                        change_reason:
                          type: string
                          example: Annual merit increase
                        changed_by:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 101
                            name:
                              type: string
                              example: Jane Manager
                        changes:
                          type: object
                          properties:
                            compensation_type:
                              type: object
                              properties:
                                from:
                                  type: string
                                  nullable: true
                                  example: salary
                                to:
                                  type: string
                                  nullable: true
                                  example: salary
                            annual_salary:
                              type: object
                              properties:
                                from:
                                  type: number
                                  nullable: true
                                  example: 70000.0
                                to:
                                  type: number
                                  nullable: true
                                  example: 75000.0
                            hourly_rate:
                              type: object
                              properties:
                                from:
                                  type: number
                                  nullable: true
                                  example:
                                to:
                                  type: number
                                  nullable: true
                                  example:
                            pay_frequency:
                              type: object
                              properties:
                                from:
                                  type: string
                                  nullable: true
                                  example: monthly
                                to:
                                  type: string
                                  nullable: true
                                  example: monthly
                            currency:
                              type: object
                              properties:
                                from:
                                  type: string
                                  nullable: true
                                  example: USD
                                to:
                                  type: string
                                  nullable: true
                                  example: USD
                        change_summary:
                          type: string
                          example: 'Salary: $70,000 → $75,000'
                        change_direction:
                          type: string
                          enum:
                          - increase
                          - decrease
                          - neutral
                          example: increase
                        is_retroactive:
                          type: boolean
                          example: false
                        effective_soon:
                          type: boolean
                          example: true
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
  "/compensation/insights":
    get:
      tags:
      - Employee Compensation
      summary: Get personalized compensation insights
      description: |
        Provides personalized compensation insights including tenure analysis, recent activity,
        request eligibility, and performance integration information.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Compensation insights retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  insights:
                    type: object
                    description: Personalized compensation insights and analytics
                    properties:
                      summary:
                        type: object
                        properties:
                          current_annualized_value:
                            type: number
                            nullable: true
                            example: 75000.0
                          compensation_type:
                            type: string
                            example: salary
                          currency:
                            type: string
                            example: USD
                          tenure_months:
                            type: integer
                            example: 18
                      recent_activity:
                        type: object
                        properties:
                          changes_last_12_months:
                            type: integer
                            example: 1
                          last_change_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-15'
                      request_eligibility:
                        type: object
                        properties:
                          can_request_now:
                            type: boolean
                            example: true
                          next_eligible_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                      performance_link:
                        type: object
                        properties:
                          has_recent_review:
                            type: boolean
                            example: false
                          merit_increase_eligible:
                            type: boolean
                            example: false
  "/compensation/calculate":
    post:
      tags:
      - Employee Compensation
      summary: Calculate compensation conversions
      description: |
        Performs real-time compensation calculations including salary to hourly conversions
        and vice versa based on configurable annual hours.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - calculation
              properties:
                calculation:
                  type: object
                  required:
                  - compensation_type
                  properties:
                    compensation_type:
                      type: string
                      enum:
                      - salary
                      - hourly
                      description: Type of compensation to calculate
                    annual_salary:
                      type: number
                      minimum: 0
                      description: Annual salary amount (required for salary type)
                    hourly_rate:
                      type: number
                      minimum: 0
                      description: Hourly rate amount (required for hourly type)
                    annual_hours:
                      type: number
                      minimum: 1
                      default: 2080
                      description: Annual working hours for calculation basis
                    currency:
                      type: string
                      default: USD
                      description: Currency code
      responses:
        '200':
          description: Calculation completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  calculation:
                    type: object
                    description: Compensation calculation result
                    properties:
                      compensation_type:
                        type: string
                        enum:
                        - salary
                        - hourly
                        example: salary
                      annual_salary:
                        type: number
                        nullable: true
                        example: 75000.0
                      calculated_hourly_rate:
                        type: number
                        nullable: true
                        example: 36.06
                      hourly_rate:
                        type: number
                        nullable: true
                        example:
                      calculated_annual_salary:
                        type: number
                        nullable: true
                        example:
                      annual_hours_basis:
                        type: number
                        example: 2080.0
                      currency:
                        type: string
                        example: USD
  "/compensation/requests":
    get:
      tags:
      - Compensation Requests
      summary: List employee compensation requests
      description: |
        Retrieves all compensation change requests submitted by the current employee
        with filtering and pagination options.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: |
          Number of items per page. Values above 100 are clamped to 100;
          blank, non-numeric and non-positive values fall back to the default.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: |
          Filter by request status. Case-sensitive, and a single scalar value.
          filter-search-audit 2026-09-02: an unrecognized value used to be
          SILENTLY IGNORED, returning every status under an unfiltered
          meta.total_count (?status=PENDING and ?status=garbage each returned
          all 170 rows for a user whose pending count is 10). It now answers
          400 invalid_status, which this path did not previously declare.
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - cancelled
      responses:
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '200':
          description: Compensation requests retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Summary view of compensation request
                      properties:
                        id:
                          type: integer
                          example: 456
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          - cancelled
                          example: pending
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-20T14:30:00Z'
                        effective_date:
                          type: string
                          format: date
                          example: '2024-03-01'
                        change_summary:
                          type: string
                          example: 'Salary: $75,000 → $80,000'
                        change_impact:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - increase
                              - decrease
                              - neutral
                              example: increase
                            amount:
                              type: number
                              example: 5000.0
                            percentage:
                              type: number
                              nullable: true
                              example: 6.7
                            description:
                              type: string
                              example: Increase of $5,000 (6.7%)
                        approver:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                        days_pending:
                          type: integer
                          example: 5
                        is_urgent:
                          type: boolean
                          example: false
                        effective_soon:
                          type: boolean
                          example: true
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
    post:
      tags:
      - Compensation Requests
      summary: Create compensation change request
      description: |
        Creates a new compensation change request. Validates against organization policies
        including request frequency limits and approval workflows.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - compensation_request
              properties:
                compensation_request:
                  type: object
                  description: Input schema for creating/updating compensation requests
                  required:
                  - justification
                  - requested_compensation_type
                  properties:
                    justification:
                      type: string
                      minLength: 10
                      maxLength: 1000
                      example: Requesting salary increase based on performance review
                        and market analysis
                    effective_date:
                      type: string
                      format: date
                      example: '2024-03-01'
                    requested_compensation_type:
                      type: string
                      enum:
                      - salary
                      - hourly
                      - commission
                      - contract
                      example: salary
                    requested_annual_salary:
                      type: number
                      minimum: 0
                      nullable: true
                      example: 80000.0
                    requested_hourly_rate:
                      type: number
                      minimum: 0
                      nullable: true
                      example:
                    requested_pay_frequency:
                      type: string
                      enum:
                      - weekly
                      - biweekly
                      - monthly
                      - quarterly
                      - annually
                      example: monthly
                    requested_currency:
                      type: string
                      example: USD
      responses:
        '201':
          description: Compensation request created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '429':
          description: Too many requests - rate limit exceeded
          headers:
            Retry-After:
              description: Seconds until rate limit reset
              schema:
                type: integer
            X-RateLimit-Limit:
              description: Total requests allowed per window
              schema:
                type: integer
            X-RateLimit-Remaining:
              description: Requests remaining in current window
              schema:
                type: integer
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/compensation/requests/{id}":
    get:
      tags:
      - Compensation Requests
      summary: Get compensation request details
      description: |
        Retrieves detailed information about a specific compensation change request
        including current and requested compensation, approval status, and change analysis.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Compensation request ID
        schema:
          type: integer
      responses:
        '200':
          description: Compensation request details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - Compensation Requests
      summary: Update compensation request
      description: 'Updates a pending compensation change request. Only pending requests
        can be edited.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Compensation request ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - compensation_request
              properties:
                compensation_request:
                  type: object
                  description: Input schema for creating/updating compensation requests
                  required:
                  - justification
                  - requested_compensation_type
                  properties:
                    justification:
                      type: string
                      minLength: 10
                      maxLength: 1000
                      example: Requesting salary increase based on performance review
                        and market analysis
                    effective_date:
                      type: string
                      format: date
                      example: '2024-03-01'
                    requested_compensation_type:
                      type: string
                      enum:
                      - salary
                      - hourly
                      - commission
                      - contract
                      example: salary
                    requested_annual_salary:
                      type: number
                      minimum: 0
                      nullable: true
                      example: 80000.0
                    requested_hourly_rate:
                      type: number
                      minimum: 0
                      nullable: true
                      example:
                    requested_pay_frequency:
                      type: string
                      enum:
                      - weekly
                      - biweekly
                      - monthly
                      - quarterly
                      - annually
                      example: monthly
                    requested_currency:
                      type: string
                      example: USD
      responses:
        '200':
          description: Compensation request updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/compensation/requests/{id}/cancel":
    patch:
      tags:
      - Compensation Requests
      summary: Cancel compensation request
      description: 'Cancels a pending compensation change request. Only pending requests
        can be cancelled.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Compensation request ID
        schema:
          type: integer
      responses:
        '200':
          description: Compensation request cancelled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
                  message:
                    type: string
                    example: Compensation request has been cancelled successfully
        '422':
          description: Request cannot be cancelled (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/epms/dashboard":
    get:
      tags:
      - EPMS Dashboard
      summary: Get EPMS dashboard
      description: |
        Retrieves combined dashboard data for the current user including goals, reviews, feedback, meetings, and action items.
        Provides an aggregated view of all EPMS activities and pending actions.

        **Required Scopes:** `read:epms_dashboard`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    description: EPMS dashboard data with aggregated goals, reviews,
                      feedback, and meetings
                    properties:
                      goals:
                        type: object
                        properties:
                          active_count:
                            type: integer
                            example: 5
                          completed_count:
                            type: integer
                            example: 12
                          overdue_count:
                            type: integer
                            example: 2
                          recent_updates:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSGoal"
                      reviews:
                        type: object
                        properties:
                          pending_count:
                            type: integer
                            example: 1
                          in_progress_count:
                            type: integer
                            example: 2
                          completed_count:
                            type: integer
                            example: 8
                          recent_reviews:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSPerformanceReview"
                      feedback:
                        type: object
                        properties:
                          received_count:
                            type: integer
                            example: 15
                          pending_acknowledgment:
                            type: integer
                            example: 3
                          recent_feedback:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSContinuousFeedback"
                      meetings:
                        type: object
                        properties:
                          upcoming_count:
                            type: integer
                            example: 4
                          recent_count:
                            type: integer
                            example: 6
                          recent_meetings:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSMeeting"
                      action_items:
                        type: array
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - goal_update
                              - review_submit
                              - feedback_acknowledge
                              - meeting_schedule
                            title:
                              type: string
                            due_date:
                              type: string
                              format: date
                              nullable: true
                            priority:
                              type: string
                              enum:
                              - low
                              - medium
                              - high
                              - critical
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/dashboard/team":
    get:
      tags:
      - EPMS Dashboard
      summary: Get team dashboard
      description: |
        Retrieves team dashboard data for managers. Includes team goals, reviews, feedback, and pending approvals.
        Requires manager permissions.

        **Required Scopes:** `read:epms_dashboard`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Team dashboard data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    description: Team dashboard data for managers
                    properties:
                      team_stats:
                        type: object
                        properties:
                          team_size:
                            type: integer
                            example: 12
                          active_goals:
                            type: integer
                            example: 45
                          pending_reviews:
                            type: integer
                            example: 8
                          pending_approvals:
                            type: integer
                            example: 5
                      team_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      pending_reviews:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSPerformanceReview"
                      team_feedback:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSContinuousFeedback"
                      upcoming_meetings:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSMeeting"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals":
    get:
      tags:
      - EPMS Goals
      summary: List goals
      description: |
        Retrieves a list of goals with filtering and pagination options. Supports filtering by scope, status, type, priority, and employee.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, team, all)
        schema:
          type: string
          enum:
          - mine
          - team
          - all
      - name: status
        in: query
        description: 'Filter by status (comma-separated: draft, in_review, active,
          on_hold, completed, cancelled, overdue)'
        schema:
          type: string
      - name: goal_type
        in: query
        description: Filter by goal type
        schema:
          type: string
          enum:
          - performance
          - development
          - behavior
          - project
          - skill
          - stretch
          - team
      - name: priority
        in: query
        description: Filter by priority
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: overdue
        in: query
        description: Filter overdue goals
        schema:
          type: boolean
      - name: search
        in: query
        description: Search by title or description
        schema:
          type: string
      - name: workflow_stage
        in: query
        description: 'Filter by workflow stage (comma-separated: draft, employee_review,
          manager_finalized, leadership_approved)'
        schema:
          type: string
      - name: start_date
        in: query
        description: Start of target_date range (inclusive)
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: End of target_date range (inclusive)
        schema:
          type: string
          format: date
      - name: due_within_days
        in: query
        description: Filter goals due within N days
        schema:
          type: integer
      - name: performance_review_id
        in: query
        description: Filter by linked performance review ID
        schema:
          type: integer
      - name: sort_by
        in: query
        description: Sort field
        schema:
          type: string
          enum:
          - created_at
          - updated_at
          - target_date
          - start_date
          - progress_percentage
          - priority
          - status
          - title
      - name: sort_order
        in: query
        description: Sort direction
        schema:
          type: string
          enum:
          - asc
          - desc
      responses:
        '200':
          description: Goals retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Employee goal with progress tracking. Progress
                        updates are returned as a sibling array in GET /goals/{id},
                        not nested in the goal object.
                      properties:
                        id:
                          type: integer
                          example: 123
                        title:
                          type: string
                          example: Increase sales by 20%
                        description:
                          type: string
                          nullable: true
                          example: Achieve 20% growth in Q1 sales
                        goal_type:
                          type: string
                          enum:
                          - performance
                          - development
                          - behavior
                          - project
                          - skill
                          example: performance
                        goal_type_label:
                          type: string
                          nullable: true
                          description: Human-readable label for goal type
                        goal_category:
                          type: string
                          nullable: true
                          description: The focus area category key for this goal
                          example: professional_development
                        goal_category_label:
                          type: string
                          nullable: true
                          description: Human-readable label for the goal category
                          example: Professional Development
                        priority:
                          type: string
                          enum:
                          - low
                          - medium
                          - high
                          - critical
                          example: high
                        priority_label:
                          type: string
                          nullable: true
                          description: Human-readable label for priority
                        status:
                          type: string
                          enum:
                          - draft
                          - in_review
                          - active
                          - on_hold
                          - completed
                          - cancelled
                          - overdue
                          example: active
                        display_status:
                          type: string
                          nullable: true
                          description: Human-readable display status
                        start_date:
                          type: string
                          format: date
                          example: '2026-01-01'
                        target_date:
                          type: string
                          format: date
                          example: '2026-03-31'
                        completed_date:
                          type: string
                          format: date
                          nullable: true
                        progress_percentage:
                          type: number
                          minimum: 0
                          maximum: 100
                          example: 45.5
                        weight_percentage:
                          type: number
                          nullable: true
                          example: 30.0
                        workflow_stage:
                          type: string
                          enum:
                          - draft
                          - employee_review
                          - manager_finalized
                          - leadership_approved
                          description: Current workflow stage
                        workflow_stage_label:
                          type: string
                          nullable: true
                        is_smart_goal:
                          type: boolean
                          example: true
                        smart_score:
                          type: number
                          nullable: true
                        on_track:
                          type: boolean
                          nullable: true
                        days_until_due:
                          type: integer
                          nullable: true
                        is_overdue:
                          type: boolean
                          example: false
                        progress_update_allowed:
                          type: boolean
                          description: Whether progress updates can be submitted for
                            this goal
                        success_criteria:
                          type: string
                          nullable: true
                          description: Present when include_details is true (e.g.
                            show endpoint)
                          example: Reach $500K in sales
                        smart_criteria:
                          type: object
                          nullable: true
                          description: Present when include_details is true
                          properties:
                            is_specific:
                              type: boolean
                            is_measurable:
                              type: boolean
                            is_achievable:
                              type: boolean
                            is_relevant:
                              type: boolean
                            is_time_bound:
                              type: boolean
                        progress_updates_count:
                          type: integer
                          description: Present when include_details is true
                        latest_progress_update:
                          "$ref": "#/components/schemas/EPMSProgressUpdate"
                          nullable: true
                          description: Present when include_details is true
                        can_edit:
                          type: boolean
                          description: Present when include_details is true
                        can_complete:
                          type: boolean
                          description: Present when include_details is true
                        can_delete:
                          type: boolean
                          description: Present when include_details is true
                        requires_manager_approval:
                          type: boolean
                          description: Present when include_details is true
                        is_fully_approved:
                          type: boolean
                          description: Present when include_details is true
                        in_review_stage:
                          type: boolean
                          description: Present when include_details is true
                        can_cancel:
                          type: boolean
                          description: Present when include_details is true
                        can_put_on_hold:
                          type: boolean
                          description: Present when include_details is true
                        can_reactivate:
                          type: boolean
                          description: Present when include_details is true
                        can_send_to_employee:
                          type: boolean
                          description: Present when include_details is true
                        can_employee_review:
                          type: boolean
                          description: Present when include_details is true
                        can_manager_finalize:
                          type: boolean
                          description: Present when include_details is true
                        can_leadership_approve:
                          type: boolean
                          description: Present when include_details is true
                        manager_approved_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Present when include_details is true
                        leadership_approved_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Present when include_details is true
                        employee:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                            email:
                              type: string
                              nullable: true
                              example: john@example.com
                            job_title:
                              type: string
                              nullable: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '400':
          description: Invalid filter parameter (e.g. invalid workflow_stage or sort_by
            value)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                      details:
                        type: object
                        properties:
                          param:
                            type: string
                          invalid_values:
                            type: array
                            items:
                              type: string
                          valid_values:
                            type: array
                            items:
                              type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Goals
      summary: Create goal
      description: |
        Creates a new goal. Employee ID defaults to current user if not specified.
        Validates goal attributes including SMART criteria.

        `goal_category` is required when creating a goal and must be a valid category key for the business;
        use `GET /epms/goals/categories` to retrieve valid values.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal
              properties:
                goal:
                  type: object
                  description: Input schema for creating/updating goals
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Increase sales by 20%
                    description:
                      type: string
                      maxLength: 2000
                      example: Achieve 20% growth in Q1 sales
                    goal_type:
                      type: string
                      enum:
                      - performance
                      - development
                      - behavior
                      - project
                      - skill
                      example: performance
                    goal_category:
                      type: string
                      maxLength: 50
                      description: 'Focus area category key. Required on create for
                        non-department goals. Must be a valid category configured
                        for the business. Use `GET /epms/goals/categories` to retrieve
                        valid values. Default categories for IC: professional_development,
                        lead_self, work_with_others, contribute_to_business. Default
                        categories for leaders: professional_development, lead_self,
                        lead_others, lead_the_business. Business configuration may
                        differ.

                        '
                      example: professional_development
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: high
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_date:
                      type: string
                      format: date
                      example: '2026-03-31'
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 0
                    weight_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 30.0
                    success_criteria:
                      type: string
                      maxLength: 500
                      example: Reach $500K in sales
                    employee_id:
                      type: integer
                      example: 456
                    is_specific:
                      type: boolean
                      example: true
                    is_measurable:
                      type: boolean
                      example: true
                    is_achievable:
                      type: boolean
                      example: true
                    is_relevant:
                      type: boolean
                      example: true
                    is_time_bound:
                      type: boolean
                      example: true
      responses:
        '201':
          description: Goal created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}":
    get:
      tags:
      - EPMS Goals
      summary: Get goal details
      description: |
        Retrieves detailed goal information including progress updates and related data.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '200':
          description: Goal details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  progress_updates:
                    type: array
                    items:
                      type: object
                      description: Goal progress update record
                      properties:
                        id:
                          type: integer
                          example: 789
                        progress_percentage:
                          type: number
                          minimum: 0
                          maximum: 100
                          example: 50.0
                        update_notes:
                          type: string
                          nullable: true
                          example: Halfway through the quarter, on track
                        update_date:
                          type: string
                          format: date
                          example: '2026-01-15'
                        update_type:
                          type: string
                          enum:
                          - regular
                          - milestone
                          - completion
                          - revision
                          - comment
                          example: regular
                        challenges_faced:
                          type: string
                          nullable: true
                        support_needed:
                          type: string
                          nullable: true
                        attachments:
                          type: array
                          items:
                            type: string
                          nullable: true
                        updated_by:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:30:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Goals
      summary: Update goal
      description: |
        Updates an existing goal. Only certain fields can be updated based on goal status.

        `goal_category` can be included in the body to update the goal's focus area (same validation as create).

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal
              properties:
                goal:
                  type: object
                  description: Input schema for creating/updating goals
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Increase sales by 20%
                    description:
                      type: string
                      maxLength: 2000
                      example: Achieve 20% growth in Q1 sales
                    goal_type:
                      type: string
                      enum:
                      - performance
                      - development
                      - behavior
                      - project
                      - skill
                      example: performance
                    goal_category:
                      type: string
                      maxLength: 50
                      description: 'Focus area category key. Required on create for
                        non-department goals. Must be a valid category configured
                        for the business. Use `GET /epms/goals/categories` to retrieve
                        valid values. Default categories for IC: professional_development,
                        lead_self, work_with_others, contribute_to_business. Default
                        categories for leaders: professional_development, lead_self,
                        lead_others, lead_the_business. Business configuration may
                        differ.

                        '
                      example: professional_development
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: high
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_date:
                      type: string
                      format: date
                      example: '2026-03-31'
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 0
                    weight_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 30.0
                    success_criteria:
                      type: string
                      maxLength: 500
                      example: Reach $500K in sales
                    employee_id:
                      type: integer
                      example: 456
                    is_specific:
                      type: boolean
                      example: true
                    is_measurable:
                      type: boolean
                      example: true
                    is_achievable:
                      type: boolean
                      example: true
                    is_relevant:
                      type: boolean
                      example: true
                    is_time_bound:
                      type: boolean
                      example: true
      responses:
        '200':
          description: Goal updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Goals
      summary: Delete goal
      description: |
        Deletes a goal if allowed based on status and permissions (can_delete?).

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '204':
          description: Goal deleted successfully
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/complete":
    post:
      tags:
      - EPMS Goals
      summary: Complete goal
      description: |
        Marks a goal as complete. Requires 100% progress or explicit completion.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '200':
          description: Goal completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal has been completed successfully
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/progress_updates":
    post:
      tags:
      - EPMS Goals
      summary: Add progress update
      description: |
        Adds a progress update to a goal. Can optionally update the goal's progress percentage.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - progress_update
              properties:
                progress_update:
                  type: object
                  description: Input schema for progress updates
                  properties:
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Required for all types except 'comment'
                      example: 50.0
                    update_notes:
                      type: string
                      maxLength: 1000
                      example: Halfway through the quarter, on track
                    update_type:
                      type: string
                      enum:
                      - regular
                      - milestone
                      - completion
                      - revision
                      - comment
                      default: regular
                      example: regular
                    challenges_faced:
                      type: string
                      nullable: true
                      description: Obstacles or challenges encountered
                      example: Dependency on external vendor delayed delivery
                    support_needed:
                      type: string
                      nullable: true
                      description: Support or resources needed
                      example: Need additional budget approval for tooling
                    attachments:
                      type: array
                      items:
                        type: string
                      nullable: true
                      description: File references/attachment identifiers
      responses:
        '201':
          description: Progress update created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  progress_update:
                    type: object
                    description: Goal progress update record
                    properties:
                      id:
                        type: integer
                        example: 789
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 50.0
                      update_notes:
                        type: string
                        nullable: true
                        example: Halfway through the quarter, on track
                      update_date:
                        type: string
                        format: date
                        example: '2026-01-15'
                      update_type:
                        type: string
                        enum:
                        - regular
                        - milestone
                        - completion
                        - revision
                        - comment
                        example: regular
                      challenges_faced:
                        type: string
                        nullable: true
                      support_needed:
                        type: string
                        nullable: true
                      attachments:
                        type: array
                        items:
                          type: string
                        nullable: true
                      updated_by:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:30:00Z'
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/submit_employee_changes":
    post:
      tags:
      - EPMS Goals
      summary: Submit employee changes
      description: |
        Employee submits reviewed goal to manager (workflow: employee_review → manager_finalized).
        Only the goal owner can call this endpoint.
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  description: Optional comments when submitting
      responses:
        '200':
          description: Goal submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/submit_for_leadership_approval":
    post:
      tags:
      - EPMS Goals
      summary: Submit for leadership approval
      description: |
        Manager finalizes goal and optionally submits for leadership approval (requires can_finalize_goal?).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  description: Optional comments
      responses:
        '200':
          description: Goal finalized / submitted for leadership approval
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal approved successfully.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/leadership_approve":
    post:
      tags:
      - EPMS Goals
      summary: Leadership approve
      description: |
        Administrator performs leadership approval (manager_finalized → leadership_approved).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  description: Optional comments
      responses:
        '200':
          description: Goal approved by leadership
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal approved by leadership.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/leadership_reject":
    post:
      tags:
      - EPMS Goals
      summary: Leadership reject
      description: |
        Administrator rejects goal and sends back for revision (requires comments).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comments
              properties:
                comments:
                  type: string
                  description: Required rejection comments
      responses:
        '200':
          description: Goal rejected and sent back for revision
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal rejected and sent back for revision.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/manager_reject":
    post:
      tags:
      - EPMS Goals
      summary: Manager reject
      description: |
        Manager rejects goal and sends back to employee (comment or rejection_comment required).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  description: Comment when requesting changes (alternative to rejection_comment)
                rejection_comment:
                  type: string
                  description: Rejection comment (alternative to comment)
      responses:
        '200':
          description: Goal sent back to employee with feedback
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal sent back to employee with your feedback.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/cancel_change_request":
    post:
      tags:
      - EPMS Goals
      summary: Cancel change request
      description: |
        Manager cancels their rejection (employee_review → manager_finalized).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '200':
          description: Change request cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Change request cancelled. Goal is ready for manager review.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/add_comment":
    post:
      tags:
      - EPMS Goals
      summary: Add comment
      description: |
        Adds a comment (GoalProgressUpdate with update_type 'comment') to a goal.
        Requires can_manage_employee_data? for the goal's employee.
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: string
                  description: Comment text (required)
      responses:
        '201':
          description: Comment created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    type: object
                    description: Goal progress update record
                    properties:
                      id:
                        type: integer
                        example: 789
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 50.0
                      update_notes:
                        type: string
                        nullable: true
                        example: Halfway through the quarter, on track
                      update_date:
                        type: string
                        format: date
                        example: '2026-01-15'
                      update_type:
                        type: string
                        enum:
                        - regular
                        - milestone
                        - completion
                        - revision
                        - comment
                        example: regular
                      challenges_faced:
                        type: string
                        nullable: true
                      support_needed:
                        type: string
                        nullable: true
                      attachments:
                        type: array
                        items:
                          type: string
                        nullable: true
                      updated_by:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:30:00Z'
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/stats":
    get:
      tags:
      - EPMS Goals
      summary: Get goal statistics
      description: |
        Retrieves goal statistics for dashboard display. Can be filtered by employee ID.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Optional filter by employee ID
        schema:
          type: integer
      responses:
        '200':
          description: Goal statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    type: object
                    description: Goal statistics for dashboard
                    properties:
                      total_goals:
                        type: integer
                        example: 20
                      active_goals:
                        type: integer
                        example: 12
                      completed_goals:
                        type: integer
                        example: 6
                      overdue_goals:
                        type: integer
                        example: 2
                      on_hold_goals:
                        type: integer
                        example: 0
                      cancelled_goals:
                        type: integer
                        example: 0
                      due_soon_count:
                        type: integer
                        description: Goals due within the next 7 days
                        example: 3
                      average_progress:
                        type: number
                        example: 45.5
                      completion_rate:
                        type: number
                        description: Percentage of completed goals (0-100)
                        example: 30.0
                      by_status:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts per status (draft, active, on_hold, completed,
                          cancelled, in_review, overdue)
                      by_priority:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts per priority (low, medium, high, critical)
                      by_type:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts per goal type (performance, development,
                          behavior, project, skill)
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/categories":
    get:
      tags:
      - EPMS Goals
      summary: List goal categories
      description: |
        Returns valid goal categories filtered by employee role. Categories are role-based
        (leader vs individual contributor). Without `employee_id`, returns categories for
        the authenticated user's role. With `employee_id`, returns categories for that
        employee's role (requires read access to that employee).

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Get categories for this employee's role type. Defaults to the
          authenticated user if omitted.
        schema:
          type: integer
      responses:
        '200':
          description: Goal categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Response containing valid goal categories for a user's
                  role
                properties:
                  categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/types":
    get:
      tags:
      - EPMS Goals
      summary: List goal types
      description: |
        Returns valid goal types configured for the business, each with a value and label.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Goal types retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal_types:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                          example: performance
                        label:
                          type: string
                          example: Performance Goal
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/analyze_smart":
    post:
      tags:
      - EPMS Goals
      summary: AI SMART analysis for a saved goal
      description: |
        Runs AI-powered SMART criteria analysis on an existing goal. Scores each criterion
        (Specific, Measurable, Achievable, Relevant, Time-bound) from 1-10, provides feedback
        and suggestions, and returns an improved title and description. The analysis results
        are persisted on the goal record.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: SMART analysis completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  overall_score:
                    type: integer
                    minimum: 0
                    maximum: 100
                    example: 75
                  criteria:
                    type: object
                    properties:
                      specific:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      measurable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      achievable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      relevant:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      time_bound:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                  improved_title:
                    type: string
                    nullable: true
                  improved_description:
                    type: string
                    nullable: true
                  summary:
                    type: string
                    nullable: true
                  goal:
                    "$ref": "#/components/schemas/EPMSGoal"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/epms/goals/analyze_smart_text":
    post:
      tags:
      - EPMS Goals
      summary: AI SMART analysis for goal text (before saving)
      description: |
        Runs AI-powered SMART criteria analysis on goal text before the goal is saved.
        Useful for providing real-time feedback during goal creation. Scores each criterion
        from 1-10 and suggests improvements.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - description
              properties:
                title:
                  type: string
                  description: The goal title to analyze
                description:
                  type: string
                  description: The goal description to analyze
                target_date:
                  type: string
                  format: date
                  description: Optional target date for time-bound analysis
                success_criteria:
                  type: string
                  description: Optional success criteria
      responses:
        '200':
          description: SMART text analysis completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  overall_score:
                    type: integer
                    minimum: 0
                    maximum: 100
                  criteria:
                    type: object
                    properties:
                      specific:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      measurable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      achievable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      relevant:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      time_bound:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                  improved_title:
                    type: string
                    nullable: true
                  improved_description:
                    type: string
                    nullable: true
                  summary:
                    type: string
                    nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/epms/goal_templates":
    get:
      tags:
      - EPMS Goal Templates
      summary: List goal templates
      description: |
        Returns active goal templates for the business, with optional filtering by goal type.
        Templates provide pre-configured goal structures that can be used to create new goals.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: goal_type
        in: query
        description: Filter templates by goal type
        schema:
          type: string
      responses:
        '200':
          description: Goal templates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalTemplate"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goal_templates/{id}":
    get:
      tags:
      - EPMS Goal Templates
      summary: Get goal template details
      description: |
        Returns detailed information about a specific goal template, including success criteria,
        SMART criteria, measurement method, and usage statistics.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Goal template details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal_template:
                    allOf:
                    - "$ref": "#/components/schemas/EPMSGoalTemplate"
                    - type: object
                      properties:
                        success_criteria:
                          type: string
                          nullable: true
                        resources_needed:
                          type: string
                          nullable: true
                        measurement_method:
                          type: string
                          nullable: true
                        smart_criteria:
                          type: object
                          properties:
                            is_specific:
                              type: boolean
                            is_measurable:
                              type: boolean
                            is_achievable:
                              type: boolean
                            is_relevant:
                              type: boolean
                            is_time_bound:
                              type: boolean
                        missing_smart_criteria:
                          type: array
                          items:
                            type: string
                        usage_statistics:
                          type: object
                          properties:
                            total_goals:
                              type: integer
                            active_goals:
                              type: integer
                            completed_goals:
                              type: integer
                            completion_rate:
                              type: number
                            last_used:
                              type: string
                              format: date-time
                              nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/creation_config":
    get:
      tags:
      - EPMS Goals
      summary: Goal creation configuration
      description: |
        Returns all dynamic properties needed to render the goal create form: goal templates,
        goal types, priorities, goal categories, SMART goal checklist configuration, and
        whether approval is required. Without `employee_id`, returns categories for
        the authenticated user's role. With `employee_id`, returns role-specific categories
        for that employee.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Get role-specific categories for this employee. Defaults to the
          authenticated user if omitted.
        schema:
          type: integer
      responses:
        '200':
          description: Goal creation configuration retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: All dynamic properties needed to render the goal create
                  form
                properties:
                  goal_templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalTemplateOption"
                  goal_types:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
                  priorities:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
                  goal_categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
                  goal_checklist:
                    "$ref": "#/components/schemas/EPMSGoalChecklist"
                  requires_approval:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews":
    get:
      tags:
      - EPMS Performance Reviews
      summary: List performance reviews
      description: |
        Retrieves a list of performance reviews with filtering options. Supports filtering by scope, status, type, and employee.

        **Required Scopes:** `read:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, my_team, all)
        schema:
          type: string
          enum:
          - mine
          - my_team
          - all
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - created
          - in_progress
          - completed
          - approved
          - archived
      - name: review_type
        in: query
        description: Filter by review type
        schema:
          type: string
          enum:
          - annual
          - biannual
          - quarterly
          - probationary
          - ad_hoc
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: overdue
        in: query
        description: Filter overdue reviews
        schema:
          type: boolean
      responses:
        '200':
          description: Performance reviews retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Performance review with assessment details
                      properties:
                        id:
                          type: integer
                          example: 234
                        title:
                          type: string
                          example: Q1 2026 Performance Review
                        review_type:
                          type: string
                          enum:
                          - annual
                          - quarterly
                          - probation
                          - project
                          - performance_improvement
                          example: quarterly
                        status:
                          type: string
                          enum:
                          - created
                          - in_progress
                          - submitted
                          - approved
                          - completed
                          - cancelled
                          example: in_progress
                        display_status:
                          type: string
                          description: Computed status that accounts for overdue reviews
                          example: in_progress
                        status_color:
                          type: string
                          description: Bootstrap badge color variant for the display
                            status
                          enum:
                          - info
                          - primary
                          - success
                          - danger
                          - secondary
                          example: primary
                        review_period_start:
                          type: string
                          format: date
                          example: '2026-01-01'
                        review_period_end:
                          type: string
                          format: date
                          example: '2026-03-31'
                        due_date:
                          type: string
                          format: date
                          example: '2026-04-15'
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        manager:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                        overall_rating:
                          type: number
                          nullable: true
                          minimum: 0
                          maximum: 5
                          example: 4.5
                        manager_comments:
                          type: string
                          nullable: true
                          example: Great progress this quarter
                        self_assessment:
                          type: object
                          nullable: true
                          properties:
                            overall_summary:
                              type: string
                            strengths:
                              type: string
                            areas_for_improvement:
                              type: string
                            self_rating:
                              type: number
                              minimum: 0
                              maximum: 5
                        linked_goals:
                          type: array
                          items:
                            "$ref": "#/components/schemas/EPMSGoal"
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                        is_overdue:
                          type: boolean
                          example: false
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Performance Reviews
      summary: Create performance review
      description: |
        Creates a new performance review. Managers only. Employee ID is required.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - performance_review
              properties:
                performance_review:
                  type: object
                  description: Input schema for creating/updating performance reviews
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 2026 Performance Review
                    employee_id:
                      type: integer
                      example: 456
                    review_type:
                      type: string
                      enum:
                      - annual
                      - quarterly
                      - probation
                      - project
                      - performance_improvement
                      example: quarterly
                    review_period_start:
                      type: string
                      format: date
                      example: '2026-01-01'
                    review_period_end:
                      type: string
                      format: date
                      example: '2026-03-31'
                    due_date:
                      type: string
                      format: date
                      example: '2026-04-15'
                    performance_review_template_id:
                      type: integer
                      nullable: true
                    manager_comments:
                      type: string
                      maxLength: 5000
                      example: Great progress this quarter
                    overall_rating:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.5
      responses:
        '201':
          description: Performance review created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}":
    get:
      tags:
      - EPMS Performance Reviews
      summary: Get performance review details
      description: |
        Retrieves detailed performance review information. Can include linked goals and ratings.

        **Required Scopes:** `read:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      - name: include_goals
        in: query
        description: Include linked goals
        schema:
          type: boolean
      - name: include_ratings
        in: query
        description: Include ratings (if user has access)
        schema:
          type: boolean
      responses:
        '200':
          description: Performance review details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Performance Reviews
      summary: Update performance review
      description: |
        Updates a performance review. Only certain fields can be updated based on review status.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - performance_review
              properties:
                performance_review:
                  type: object
                  description: Input schema for creating/updating performance reviews
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 2026 Performance Review
                    employee_id:
                      type: integer
                      example: 456
                    review_type:
                      type: string
                      enum:
                      - annual
                      - quarterly
                      - probation
                      - project
                      - performance_improvement
                      example: quarterly
                    review_period_start:
                      type: string
                      format: date
                      example: '2026-01-01'
                    review_period_end:
                      type: string
                      format: date
                      example: '2026-03-31'
                    due_date:
                      type: string
                      format: date
                      example: '2026-04-15'
                    performance_review_template_id:
                      type: integer
                      nullable: true
                    manager_comments:
                      type: string
                      maxLength: 5000
                      example: Great progress this quarter
                    overall_rating:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.5
      responses:
        '200':
          description: Performance review updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Performance Reviews
      summary: Delete performance review
      description: |
        Deletes a performance review if allowed based on status and permissions.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '204':
          description: Performance review deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/submit":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Submit performance review
      description: |
        Submits a performance review for approval. Review must be in progress status.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '200':
          description: Performance review submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
                  message:
                    type: string
                    example: Performance review has been submitted for approval
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/approve":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Approve performance review
      description: |
        Approves a submitted performance review. Requires manager or admin permissions.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '200':
          description: Performance review approved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
                  message:
                    type: string
                    example: Performance review has been approved
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/archive":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Archive performance review
      description: |
        Archives a completed or approved performance review — the recoverable
        alternative to deletion. Requires the review's reviewer or HR/admin
        permissions.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '200':
          description: Performance review archived successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/self_assessment":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Submit self assessment
      description: |
        Submits employee self-assessment for a performance review. Includes overall summary, strengths, areas for improvement, and ratings.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - self_assessment
              properties:
                self_assessment:
                  type: object
                  description: Input schema for self-assessment submission
                  properties:
                    overall_summary:
                      type: string
                      maxLength: 2000
                      example: I achieved all my goals this quarter
                    strengths:
                      type: string
                      maxLength: 1000
                      example: Strong communication and leadership
                    areas_for_improvement:
                      type: string
                      maxLength: 1000
                      example: Time management
                    accomplishments:
                      type: string
                      maxLength: 2000
                      example: Completed 3 major projects
                    goals_reflection:
                      type: string
                      maxLength: 1000
                      example: Met 90% of goals
                    training_needs:
                      type: string
                      maxLength: 500
                      example: Advanced project management
                    career_aspirations:
                      type: string
                      maxLength: 1000
                      example: Lead a larger team
                    self_rating:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.0
                    ratings:
                      type: array
                      items:
                        type: object
                        properties:
                          category:
                            type: string
                            example: Communication
                          rating:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 4.5
                          comments:
                            type: string
                            example: Excellent
                    goals:
                      type: array
                      items:
                        type: object
                        properties:
                          goal_id:
                            type: integer
                            example: 123
                          achievement_notes:
                            type: string
                            example: Exceeded expectations
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 5.0
      responses:
        '200':
          description: Self assessment submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
                  message:
                    type: string
                    example: Self assessment has been submitted
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/link_goals":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Link goals to review
      description: |
        Links goals to a performance review for evaluation.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal_ids
              properties:
                goal_ids:
                  type: array
                  items:
                    type: integer
                  description: Array of goal IDs to link
      responses:
        '200':
          description: Goals linked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: Performance review with assessment details
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      review_type:
                        type: string
                        enum:
                        - annual
                        - quarterly
                        - probation
                        - project
                        - performance_improvement
                        example: quarterly
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - submitted
                        - approved
                        - completed
                        - cancelled
                        example: in_progress
                      display_status:
                        type: string
                        description: Computed status that accounts for overdue reviews
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - success
                        - danger
                        - secondary
                        example: primary
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        example: '2026-04-15'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      overall_rating:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.5
                      manager_comments:
                        type: string
                        nullable: true
                        example: Great progress this quarter
                      self_assessment:
                        type: object
                        nullable: true
                        properties:
                          overall_summary:
                            type: string
                          strengths:
                            type: string
                          areas_for_improvement:
                            type: string
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                      linked_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      is_overdue:
                        type: boolean
                        example: false
                  message:
                    type: string
                    example: Goals have been linked to the performance review
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/stats":
    get:
      tags:
      - EPMS Performance Reviews
      summary: Get performance review statistics
      description: |
        Retrieves performance review statistics. Can be filtered by employee ID.
        Also returns `my_review_summary` with the current user's personal review
        summary matching the "My Review Status" widget on the web dashboard for
        single-employee contexts (`scope=mine` or default scope without aggregate views).

        **Required Scopes:** `read:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Optional filter by employee ID
        schema:
          type: integer
      - name: scope
        in: query
        description: Optional scope filter. Aggregate scopes omit `my_review_summary`.
        schema:
          type: string
          enum:
          - mine
          - my_team
          - all
      responses:
        '200':
          description: Performance review statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    type: object
                    description: Performance review statistics
                    properties:
                      total_reviews:
                        type: integer
                        example: 15
                      pending_reviews:
                        type: integer
                        example: 5
                      completed_reviews:
                        type: integer
                        example: 8
                      overdue_reviews:
                        type: integer
                        example: 2
                      due_soon_count:
                        type: integer
                        example: 3
                      completion_rate:
                        type: number
                        example: 53.3
                      by_status:
                        type: object
                        description: Count of reviews by status key
                        additionalProperties:
                          type: integer
                        example:
                          created: 2
                          in_progress: 5
                          completed: 3
                          approved: 2
                          archived: 1
                      by_type:
                        type: object
                        description: Count of reviews by review_type key
                        additionalProperties:
                          type: integer
                        example:
                          annual: 8
                          quarterly: 5
                          ad_hoc: 2
                      average_rating:
                        type: number
                        nullable: true
                        example: 4.2
                  my_review_summary:
                    type: object
                    description: Personal review summary for the current user (matches
                      "My Review Status" widget)
                    properties:
                      total_reviews:
                        type: integer
                        description: Total number of reviews where the user is the
                          employee
                        example: 5
                      completed_reviews:
                        type: integer
                        description: Reviews with status completed or approved
                        example: 3
                      pending_reviews:
                        type: integer
                        description: Reviews with status created or in_progress
                        example: 1
                      average_rating:
                        type: number
                        description: Average overall_rating across completed/approved
                          reviews (rounded to 1 decimal, defaults to 0)
                        example: 4.2
                      has_active_review:
                        type: boolean
                        description: Whether the user has an active (created or in_progress)
                          review
                        example: true
                      current_review:
                        type: object
                        nullable: true
                        description: The most recent active review summary
                        properties:
                          id:
                            type: integer
                          title:
                            type: string
                          status:
                            type: string
                          display_status:
                            type: string
                          status_color:
                            type: string
                            enum:
                            - info
                            - primary
                            - success
                            - danger
                            - secondary
                          start_date:
                            type: string
                            format: date
                            nullable: true
                          due_date:
                            type: string
                            format: date
                            nullable: true
                          days_until_due:
                            type: integer
                            nullable: true
                          completion_percentage:
                            type: integer
                          requires_self_assessment:
                            type: boolean
                          self_assessment_completed:
                            type: boolean
                      pending_actions:
                        type: array
                        description: Status-based action items for the user
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - begin_review
                              - complete_self_assessment
                              - awaiting_approval
                            text:
                              type: string
                            priority:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: List feedback
      description: |
        Retrieves a list of feedback with filtering options. Supports filtering by scope, status, type, visibility, and receiver.

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (given, received, team, all)
        schema:
          type: string
          enum:
          - given
          - received
          - team
          - all
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - draft
          - sent
          - acknowledged
          - archived
      - name: feedback_type
        in: query
        description: Filter by feedback type
        schema:
          type: string
          enum:
          - praise
          - constructive
          - goal_progress
          - general
      - name: visibility
        in: query
        description: Filter by visibility
        schema:
          type: string
          enum:
          - private
          - manager
          - hr
          - public
      - name: receiver_id
        in: query
        description: Filter by receiver ID
        schema:
          type: integer
      - name: pending_acknowledgment
        in: query
        description: Filter pending acknowledgment
        schema:
          type: boolean
      responses:
        '200':
          description: Feedback retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Continuous feedback record
                      properties:
                        id:
                          type: integer
                          example: 345
                        subject:
                          type: string
                          example: Great work on the project
                        content:
                          type: string
                          example: Your presentation was excellent and well-received
                            by the team.
                        feedback_type:
                          type: string
                          enum:
                          - praise
                          - constructive
                          - goal_progress
                          - general
                          example: praise
                        status:
                          type: string
                          enum:
                          - draft
                          - sent
                          - acknowledged
                          - archived
                          example: sent
                        visibility:
                          type: string
                          enum:
                          - private
                          - public
                          - manager_only
                          example: private
                        is_anonymous:
                          type: boolean
                          example: false
                        giver:
                          type: object
                          nullable: true
                          description: 'Feedback giver info. Returns only `{ name:
                            "Anonymous" }` when feedback is anonymous.'
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                            email:
                              type: string
                              format: email
                              example: jane.manager@company.com
                            job_title:
                              type: string
                              nullable: true
                              example: Engineering Manager
                            profile_photo_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Full-size profile photo URL (200x200)
                              example: https://example.com/photos/jane-200x200.jpg
                            profile_photo_thumbnail_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Thumbnail profile photo URL (40x40)
                              example: https://example.com/photos/jane-40x40.jpg
                        receiver:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                            email:
                              type: string
                              format: email
                              example: john.doe@company.com
                            job_title:
                              type: string
                              nullable: true
                              example: Senior Product Manager
                            profile_photo_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Full-size profile photo URL (200x200)
                              example: https://example.com/photos/john-200x200.jpg
                            profile_photo_thumbnail_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Thumbnail profile photo URL (40x40)
                              example: https://example.com/photos/john-40x40.jpg
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-01-20T10:00:00Z'
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                        sent_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-01-15T15:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Create feedback (draft)
      description: |
        Creates new feedback. Starts as draft status. Must be sent separately.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feedback
              properties:
                feedback:
                  type: object
                  description: Input schema for creating/updating feedback
                  required:
                  - receiver_id
                  - subject
                  - content
                  properties:
                    receiver_id:
                      type: integer
                      example: 456
                    subject:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Great work on the project
                    content:
                      type: string
                      minLength: 1
                      maxLength: 2000
                      example: Your presentation was excellent and well-received by
                        the team.
                    feedback_type:
                      type: string
                      enum:
                      - praise
                      - constructive
                      - goal_progress
                      - general
                      default: praise
                      example: praise
                    visibility:
                      type: string
                      enum:
                      - private
                      - public
                      - manager_only
                      default: private
                      example: private
                    is_anonymous:
                      type: boolean
                      default: false
                      example: false
      responses:
        '201':
          description: Feedback created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: Get feedback details
      description: |
        Retrieves detailed feedback information.

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Continuous Feedback
      summary: Update feedback
      description: |
        Updates feedback. Only drafts can be edited by the giver.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feedback
              properties:
                feedback:
                  type: object
                  description: Input schema for creating/updating feedback
                  required:
                  - receiver_id
                  - subject
                  - content
                  properties:
                    receiver_id:
                      type: integer
                      example: 456
                    subject:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Great work on the project
                    content:
                      type: string
                      minLength: 1
                      maxLength: 2000
                      example: Your presentation was excellent and well-received by
                        the team.
                    feedback_type:
                      type: string
                      enum:
                      - praise
                      - constructive
                      - goal_progress
                      - general
                      default: praise
                      example: praise
                    visibility:
                      type: string
                      enum:
                      - private
                      - public
                      - manager_only
                      default: private
                      example: private
                    is_anonymous:
                      type: boolean
                      default: false
                      example: false
      responses:
        '200':
          description: Feedback updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Continuous Feedback
      summary: Delete feedback
      description: |
        Deletes feedback. Only drafts can be deleted by the giver.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '204':
          description: Feedback deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}/send":
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Send feedback
      description: |
        Sends the feedback to the receiver. Changes status from draft to sent.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
                  message:
                    type: string
                    example: Feedback has been sent
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}/acknowledge":
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Acknowledge feedback
      description: |
        Acknowledges received feedback. Changes status from sent to acknowledged.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback acknowledged successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
                  message:
                    type: string
                    example: Feedback has been acknowledged
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}/archive":
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Archive feedback
      description: |
        Archives feedback. Changes status to archived.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback archived successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
                  message:
                    type: string
                    example: Feedback has been archived
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/stats":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: Get feedback statistics
      description: |
        Retrieves feedback statistics. Can be filtered by scope.

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        description: Filter by scope (given, received)
        schema:
          type: string
          enum:
          - given
          - received
      responses:
        '200':
          description: Feedback statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    type: object
                    description: Feedback statistics
                    properties:
                      given:
                        type: object
                        properties:
                          total:
                            type: integer
                            example: 25
                          by_type:
                            type: object
                            properties:
                              praise:
                                type: integer
                                example: 15
                              constructive:
                                type: integer
                                example: 5
                              recognition:
                                type: integer
                                example: 3
                              coaching:
                                type: integer
                                example: 2
                      received:
                        type: object
                        properties:
                          total:
                            type: integer
                            example: 18
                          pending_acknowledgment:
                            type: integer
                            example: 3
                          acknowledged:
                            type: integer
                            example: 15
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/user_picker":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: User picker for feedback giving / receiving
      description: |
        Returns a paginated, searchable list of eligible users for the mobile
        feedback user-picker, scoped by the requested mode.

        | Mode | Returns |
        |------|---------|
        | `giving` | All active business members except the current user — anyone can receive feedback |
        | `receiving` | Users whose received-feedback the current user may view: **HR/Admin** → all users; **Managers** → themselves + direct reports; **Employees** → all active users (for filtering their own inbox) |

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: mode
        in: query
        required: true
        description: |
          Picker mode:
          - `giving` — users the current user can give feedback **to**
          - `receiving` — users whose received feedback the current user can view
        schema:
          type: string
          enum:
          - giving
          - receiving
        example: giving
      - name: search
        in: query
        description: Search by full name or email address (case-insensitive)
        schema:
          type: string
          example: Jane
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Eligible users retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: |
                        Lightweight user object returned by the feedback user-picker endpoint.
                        Used for selecting recipients (giving mode) or viewing feedback targets (receiving mode).
                      properties:
                        id:
                          type: integer
                          description: User's internal ID
                          example: 456
                        name:
                          type: string
                          description: User's full name
                          example: Jane Smith
                        email:
                          type: string
                          format: email
                          nullable: true
                          description: User's email address
                          example: jane.smith@company.com
                        job_title:
                          type: string
                          nullable: true
                          description: User's job title
                          example: Senior Engineer
                        department:
                          type: string
                          nullable: true
                          description: User's department name
                          example: Engineering
                        avatar_url:
                          type: string
                          format: uri
                          nullable: true
                          description: Full-size profile photo URL (200×200 px)
                          example: https://example.com/photos/jane-200x200.jpg
                        avatar_thumbnail_url:
                          type: string
                          format: uri
                          nullable: true
                          description: Thumbnail profile photo URL (40×40 px)
                          example: https://example.com/photos/jane-40x40.jpg
                      required:
                      - id
                      - name
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
              example:
                items:
                - id: 456
                  name: Jane Smith
                  email: jane.smith@company.com
                  job_title: Senior Engineer
                  department: Engineering
                  avatar_url: https://example.com/photos/jane-200x200.jpg
                  avatar_thumbnail_url: https://example.com/photos/jane-40x40.jpg
                - id: 789
                  name: John Doe
                  email: john.doe@company.com
                  job_title: Product Manager
                  department: Product
                  avatar_url:
                  avatar_thumbnail_url:
                meta:
                  total_count: 42
                  total_pages: 2
                  current_page: 1
                  per_page: 25
                  has_next_page: true
                  has_prev_page: false
        '400':
          description: Invalid mode parameter
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: invalid_mode
                      message:
                        type: string
                        example: 'Invalid mode ''foo''. Valid values are: giving,
                          receiving'
                      details:
                        type: object
                        properties:
                          valid_modes:
                            type: array
                            items:
                              type: string
                            example:
                            - giving
                            - receiving
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/development_plans":
    get:
      tags:
      - EPMS Development Plans
      summary: List development plans
      description: |
        Retrieves a list of development plans with filtering options.

        **Required Scopes:** `read:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, team)
        schema:
          type: string
          enum:
          - mine
          - team
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - draft
          - active
          - on_hold
          - completed
          - cancelled
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      responses:
        '200':
          description: Development plans retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Employee development plan
                      properties:
                        id:
                          type: integer
                          example: 456
                        title:
                          type: string
                          example: Leadership Development Plan
                        description:
                          type: string
                          nullable: true
                          example: Focus on developing leadership skills
                        status:
                          type: string
                          enum:
                          - draft
                          - active
                          - completed
                          - cancelled
                          example: active
                        start_date:
                          type: string
                          format: date
                          example: '2026-01-01'
                        target_completion_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-12-31'
                        plan_type:
                          type: string
                          nullable: true
                          example: leadership
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        manager:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                        goals:
                          type: array
                          items:
                            "$ref": "#/components/schemas/EPMSGoal"
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Development Plans
      summary: Create development plan
      description: |
        Creates a new development plan.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - development_plan
              properties:
                development_plan:
                  type: object
                  description: Input schema for creating/updating development plans
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Leadership Development Plan
                    description:
                      type: string
                      maxLength: 2000
                      example: Focus on developing leadership skills
                    status:
                      type: string
                      enum:
                      - draft
                      - active
                      - completed
                      - cancelled
                      example: active
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_completion_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-12-31'
                    employee_id:
                      type: integer
                      example: 456
                    plan_type:
                      type: string
                      example: leadership
      responses:
        '201':
          description: Development plan created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/development_plans/{id}":
    get:
      tags:
      - EPMS Development Plans
      summary: Get development plan details
      description: |
        Retrieves detailed development plan information. Can include development goals.

        **Required Scopes:** `read:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      - name: include_goals
        in: query
        description: Include development goals
        schema:
          type: boolean
      responses:
        '200':
          description: Development plan details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Development Plans
      summary: Update development plan
      description: |
        Updates a development plan.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - development_plan
              properties:
                development_plan:
                  type: object
                  description: Input schema for creating/updating development plans
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Leadership Development Plan
                    description:
                      type: string
                      maxLength: 2000
                      example: Focus on developing leadership skills
                    status:
                      type: string
                      enum:
                      - draft
                      - active
                      - completed
                      - cancelled
                      example: active
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_completion_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-12-31'
                    employee_id:
                      type: integer
                      example: 456
                    plan_type:
                      type: string
                      example: leadership
      responses:
        '200':
          description: Development plan updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Development Plans
      summary: Delete development plan
      description: |
        Deletes a development plan if allowed based on status and permissions.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      responses:
        '204':
          description: Development plan deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/development_plans/{id}/goals":
    post:
      tags:
      - EPMS Development Plans
      summary: Add goal to development plan
      description: |
        Adds a development goal to the plan.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal
              properties:
                goal:
                  type: object
                  description: Input schema for development plan goals
                  required:
                  - title
                  - target_date
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Complete Leadership Training
                    description:
                      type: string
                      maxLength: 2000
                      example: Finish the leadership certification program
                    goal_type:
                      type: string
                      enum:
                      - skill
                      - experience
                      - education
                      - certification
                      - project
                      example: skill
                    status:
                      type: string
                      enum:
                      - not_started
                      - in_progress
                      - completed
                      - cancelled
                      example: not_started
                    target_date:
                      type: string
                      format: date
                      example: '2026-06-30'
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      default: 0
                      example: 0
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: high
      responses:
        '201':
          description: Goal added successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings":
    get:
      tags:
      - EPMS Meetings
      summary: List meetings
      description: |
        Retrieves a list of meetings (1:1s, check-ins) with filtering options.

        **Required Scopes:** `read:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, managed, team, all)
        schema:
          type: string
          enum:
          - mine
          - managed
          - team
          - all
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - scheduled
          - confirmed
          - in_progress
          - completed
          - cancelled
          - rescheduled
      - name: meeting_type
        in: query
        description: Filter by meeting type
        schema:
          type: string
          enum:
          - performance_review
          - goal_discussion
          - feedback_session
          - check_in
          - development_planning
          - career_discussion
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: upcoming
        in: query
        description: Filter upcoming meetings only
        schema:
          type: boolean
      responses:
        '200':
          description: Meetings retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: One-on-one meeting or check-in
                      properties:
                        id:
                          type: integer
                          example: 567
                        title:
                          type: string
                          example: Q1 Check-in
                        meeting_type:
                          type: string
                          enum:
                          - performance_review
                          - goal_discussion
                          - feedback_session
                          - check_in
                          - development_planning
                          - career_discussion
                          example: check_in
                        status:
                          type: string
                          enum:
                          - scheduled
                          - confirmed
                          - in_progress
                          - completed
                          - cancelled
                          - rescheduled
                          example: scheduled
                        scheduled_at:
                          type: string
                          format: date-time
                          example: '2026-02-15T10:00:00Z'
                        duration_minutes:
                          type: integer
                          example: 30
                        location:
                          type: string
                          nullable: true
                          example: Conference Room A
                        description:
                          type: string
                          nullable: true
                          example: Quarterly performance check-in
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        manager:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                        notes:
                          type: string
                          nullable: true
                          example: Great discussion about goals and progress
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-02-15T10:30:00Z'
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Meetings
      summary: Create meeting
      description: |
        Schedules a new meeting (1:1 or check-in).

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - meeting
              properties:
                meeting:
                  type: object
                  description: Input schema for creating/updating meetings
                  required:
                  - title
                  - meeting_type
                  - employee_id
                  - scheduled_at
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 Check-in
                    meeting_type:
                      type: string
                      enum:
                      - performance_review
                      - goal_discussion
                      - feedback_session
                      - check_in
                      - development_planning
                      - career_discussion
                      example: check_in
                    employee_id:
                      type: integer
                      example: 456
                    scheduled_at:
                      type: string
                      format: date-time
                      example: '2026-02-15T10:00:00Z'
                    duration_minutes:
                      type: integer
                      default: 30
                      example: 30
                    location:
                      type: string
                      maxLength: 255
                      example: Conference Room A
                    description:
                      type: string
                      maxLength: 2000
                      example: Quarterly performance check-in
      responses:
        '201':
          description: Meeting created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings/{id}":
    get:
      tags:
      - EPMS Meetings
      summary: Get meeting details
      description: |
        Retrieves detailed meeting information.

        **Required Scopes:** `read:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      responses:
        '200':
          description: Meeting details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Meetings
      summary: Update meeting
      description: |
        Updates a meeting.

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - meeting
              properties:
                meeting:
                  type: object
                  description: Input schema for creating/updating meetings
                  required:
                  - title
                  - meeting_type
                  - employee_id
                  - scheduled_at
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 Check-in
                    meeting_type:
                      type: string
                      enum:
                      - performance_review
                      - goal_discussion
                      - feedback_session
                      - check_in
                      - development_planning
                      - career_discussion
                      example: check_in
                    employee_id:
                      type: integer
                      example: 456
                    scheduled_at:
                      type: string
                      format: date-time
                      example: '2026-02-15T10:00:00Z'
                    duration_minutes:
                      type: integer
                      default: 30
                      example: 30
                    location:
                      type: string
                      maxLength: 255
                      example: Conference Room A
                    description:
                      type: string
                      maxLength: 2000
                      example: Quarterly performance check-in
      responses:
        '200':
          description: Meeting updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Meetings
      summary: Delete meeting
      description: |
        Cancels/deletes a meeting.

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      responses:
        '204':
          description: Meeting deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings/{id}/complete":
    post:
      tags:
      - EPMS Meetings
      summary: Complete meeting
      description: |
        Marks a meeting as complete with optional notes.

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  description: Meeting completion notes
      responses:
        '200':
          description: Meeting completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                  message:
                    type: string
                    example: Meeting has been completed
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_frameworks":
    get:
      tags:
      - EPMS Competency Frameworks
      summary: List competency frameworks
      description: |
        Retrieves a list of competency frameworks. Read-only for most users.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: framework_type
        in: query
        description: Filter by framework type
        schema:
          type: string
          enum:
          - core
          - leadership
          - technical
          - functional
          - role_specific
      - name: search
        in: query
        description: Search by name or description
        schema:
          type: string
      responses:
        '200':
          description: Competency frameworks retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Competency framework with defined competencies
                      properties:
                        id:
                          type: integer
                          example: 678
                        name:
                          type: string
                          example: Leadership Competencies
                        description:
                          type: string
                          nullable: true
                          example: Core leadership competencies for management roles
                        framework_type:
                          type: string
                          enum:
                          - core
                          - leadership
                          - technical
                          - functional
                          - role_specific
                          example: leadership
                        competencies:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Communication
                              description:
                                type: string
                                nullable: true
                                example: Ability to communicate effectively
                              level:
                                type: string
                                enum:
                                - beginner
                                - intermediate
                                - advanced
                                - expert
                                nullable: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_frameworks/{id}":
    get:
      tags:
      - EPMS Competency Frameworks
      summary: Get competency framework details
      description: |
        Retrieves detailed framework information with competencies.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Competency framework ID
        schema:
          type: integer
      responses:
        '200':
          description: Competency framework details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_framework:
                    type: object
                    description: Competency framework with defined competencies
                    properties:
                      id:
                        type: integer
                        example: 678
                      name:
                        type: string
                        example: Leadership Competencies
                      description:
                        type: string
                        nullable: true
                        example: Core leadership competencies for management roles
                      framework_type:
                        type: string
                        enum:
                        - core
                        - leadership
                        - technical
                        - functional
                        - role_specific
                        example: leadership
                      competencies:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Communication
                            description:
                              type: string
                              nullable: true
                              example: Ability to communicate effectively
                            level:
                              type: string
                              enum:
                              - beginner
                              - intermediate
                              - advanced
                              - expert
                              nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_assessments":
    get:
      tags:
      - EPMS Competency Assessments
      summary: List competency assessments
      description: |
        Retrieves a list of competency assessments with filtering options.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, team)
        schema:
          type: string
          enum:
          - mine
          - team
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - draft
          - in_progress
          - completed
          - approved
          - cancelled
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: competency_framework_id
        in: query
        description: Filter by framework ID
        schema:
          type: integer
      responses:
        '200':
          description: Competency assessments retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Competency assessment with ratings
                      properties:
                        id:
                          type: integer
                          example: 789
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        competency_framework:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 678
                            name:
                              type: string
                              example: Leadership Competencies
                        assessment_type:
                          type: string
                          enum:
                          - self_assessment
                          - manager_assessment
                          - peer_assessment
                          - 360_assessment
                          example: manager_assessment
                        status:
                          type: string
                          enum:
                          - draft
                          - in_progress
                          - completed
                          example: completed
                        assessed_at:
                          type: string
                          format: date
                          example: '2026-01-15'
                        due_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-01-31'
                        overall_score:
                          type: number
                          nullable: true
                          minimum: 0
                          maximum: 5
                          example: 4.2
                        overall_comments:
                          type: string
                          nullable: true
                          example: Strong performance across all competencies
                        competency_ratings:
                          type: array
                          items:
                            type: object
                            properties:
                              competency_id:
                                type: integer
                                example: 1
                              competency_name:
                                type: string
                                example: Communication
                              rating_value:
                                type: number
                                minimum: 0
                                maximum: 5
                                example: 4.5
                              comments:
                                type: string
                                nullable: true
                                example: Excellent communication skills
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Competency Assessments
      summary: Create competency assessment
      description: |
        Creates a new competency assessment with ratings.

        **Required Scopes:** `write:epms_competencies`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - competency_assessment
              properties:
                competency_assessment:
                  type: object
                  description: Input schema for creating/updating competency assessments
                  required:
                  - employee_id
                  - competency_framework_id
                  properties:
                    employee_id:
                      type: integer
                      example: 456
                    competency_framework_id:
                      type: integer
                      example: 678
                    assessment_type:
                      type: string
                      enum:
                      - self_assessment
                      - manager_assessment
                      - peer_assessment
                      - 360_assessment
                      example: manager_assessment
                    assessed_at:
                      type: string
                      format: date
                      example: '2026-01-15'
                    due_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-01-31'
                    overall_score:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.2
                    overall_comments:
                      type: string
                      maxLength: 2000
                      example: Strong performance across all competencies
                    status:
                      type: string
                      enum:
                      - draft
                      - in_progress
                      - completed
                      example: completed
                    competency_ratings_attributes:
                      type: array
                      items:
                        type: object
                        required:
                        - competency_id
                        - rating_value
                        properties:
                          competency_id:
                            type: integer
                            example: 1
                          rating_value:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 4.5
                          comments:
                            type: string
                            maxLength: 1000
                            example: Excellent communication skills
      responses:
        '201':
          description: Competency assessment created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_assessment:
                    type: object
                    description: Competency assessment with ratings
                    properties:
                      id:
                        type: integer
                        example: 789
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      competency_framework:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 678
                          name:
                            type: string
                            example: Leadership Competencies
                      assessment_type:
                        type: string
                        enum:
                        - self_assessment
                        - manager_assessment
                        - peer_assessment
                        - 360_assessment
                        example: manager_assessment
                      status:
                        type: string
                        enum:
                        - draft
                        - in_progress
                        - completed
                        example: completed
                      assessed_at:
                        type: string
                        format: date
                        example: '2026-01-15'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-01-31'
                      overall_score:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.2
                      overall_comments:
                        type: string
                        nullable: true
                        example: Strong performance across all competencies
                      competency_ratings:
                        type: array
                        items:
                          type: object
                          properties:
                            competency_id:
                              type: integer
                              example: 1
                            competency_name:
                              type: string
                              example: Communication
                            rating_value:
                              type: number
                              minimum: 0
                              maximum: 5
                              example: 4.5
                            comments:
                              type: string
                              nullable: true
                              example: Excellent communication skills
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_assessments/{id}":
    get:
      tags:
      - EPMS Competency Assessments
      summary: Get competency assessment details
      description: |
        Retrieves detailed assessment information.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Competency assessment ID
        schema:
          type: integer
      responses:
        '200':
          description: Competency assessment details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_assessment:
                    type: object
                    description: Competency assessment with ratings
                    properties:
                      id:
                        type: integer
                        example: 789
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      competency_framework:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 678
                          name:
                            type: string
                            example: Leadership Competencies
                      assessment_type:
                        type: string
                        enum:
                        - self_assessment
                        - manager_assessment
                        - peer_assessment
                        - 360_assessment
                        example: manager_assessment
                      status:
                        type: string
                        enum:
                        - draft
                        - in_progress
                        - completed
                        example: completed
                      assessed_at:
                        type: string
                        format: date
                        example: '2026-01-15'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-01-31'
                      overall_score:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.2
                      overall_comments:
                        type: string
                        nullable: true
                        example: Strong performance across all competencies
                      competency_ratings:
                        type: array
                        items:
                          type: object
                          properties:
                            competency_id:
                              type: integer
                              example: 1
                            competency_name:
                              type: string
                              example: Communication
                            rating_value:
                              type: number
                              minimum: 0
                              maximum: 5
                              example: 4.5
                            comments:
                              type: string
                              nullable: true
                              example: Excellent communication skills
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Competency Assessments
      summary: Update competency assessment
      description: |
        Updates an assessment.

        **Required Scopes:** `write:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Competency assessment ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - competency_assessment
              properties:
                competency_assessment:
                  type: object
                  description: Input schema for creating/updating competency assessments
                  required:
                  - employee_id
                  - competency_framework_id
                  properties:
                    employee_id:
                      type: integer
                      example: 456
                    competency_framework_id:
                      type: integer
                      example: 678
                    assessment_type:
                      type: string
                      enum:
                      - self_assessment
                      - manager_assessment
                      - peer_assessment
                      - 360_assessment
                      example: manager_assessment
                    assessed_at:
                      type: string
                      format: date
                      example: '2026-01-15'
                    due_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-01-31'
                    overall_score:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.2
                    overall_comments:
                      type: string
                      maxLength: 2000
                      example: Strong performance across all competencies
                    status:
                      type: string
                      enum:
                      - draft
                      - in_progress
                      - completed
                      example: completed
                    competency_ratings_attributes:
                      type: array
                      items:
                        type: object
                        required:
                        - competency_id
                        - rating_value
                        properties:
                          competency_id:
                            type: integer
                            example: 1
                          rating_value:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 4.5
                          comments:
                            type: string
                            maxLength: 1000
                            example: Excellent communication skills
      responses:
        '200':
          description: Competency assessment updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_assessment:
                    type: object
                    description: Competency assessment with ratings
                    properties:
                      id:
                        type: integer
                        example: 789
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      competency_framework:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 678
                          name:
                            type: string
                            example: Leadership Competencies
                      assessment_type:
                        type: string
                        enum:
                        - self_assessment
                        - manager_assessment
                        - peer_assessment
                        - 360_assessment
                        example: manager_assessment
                      status:
                        type: string
                        enum:
                        - draft
                        - in_progress
                        - completed
                        example: completed
                      assessed_at:
                        type: string
                        format: date
                        example: '2026-01-15'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-01-31'
                      overall_score:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.2
                      overall_comments:
                        type: string
                        nullable: true
                        example: Strong performance across all competencies
                      competency_ratings:
                        type: array
                        items:
                          type: object
                          properties:
                            competency_id:
                              type: integer
                              example: 1
                            competency_name:
                              type: string
                              example: Communication
                            rating_value:
                              type: number
                              minimum: 0
                              maximum: 5
                              example: 4.5
                            comments:
                              type: string
                              nullable: true
                              example: Excellent communication skills
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests":
    get:
      tags:
      - Leave Management
      summary: List user's leave requests
      description: |
        Get a paginated list of leave requests for the authenticated user.
        Supports filtering by status, leave type, and date ranges.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: Filter by status (comma-separated for multiple)
        schema:
          type: string
          example: pending,approved
      - name: leave_type_id
        in: query
        description: Filter by leave type ID
        schema:
          type: integer
          example: 1
      - name: start_date
        in: query
        description: Filter requests starting from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter requests ending before this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: period
        in: query
        description: Filter by time period
        schema:
          type: string
          enum:
          - upcoming
          - past
          - current
          example: upcoming
      responses:
        '200':
          description: Leave requests retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Basic leave request information
                      properties:
                        id:
                          type: integer
                          description: Unique leave request ID
                          example: 123
                        user_id:
                          type: integer
                          description: ID of the user who created the request
                          example: 456
                        leave_type:
                          "$ref": "#/components/schemas/LeaveTypeBasic"
                        start_date:
                          type: string
                          format: date
                          description: Start date of leave
                          example: '2024-03-15'
                        end_date:
                          type: string
                          format: date
                          description: End date of leave
                          example: '2024-03-17'
                        hours_calculated:
                          type: number
                          description: Total hours for this leave request (primary
                            field)
                          example: 24
                        business_days:
                          type: number
                          description: 'DEPRECATED: Use hours_calculated instead.
                            Returns same value as hours_calculated for backward compatibility.'
                          example: 24
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - denied
                          - cancelled
                          - special_approval
                          description: Current status of the leave request
                          example: pending
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes for the leave request
                          example: Family vacation
                        created_at:
                          type: string
                          format: date-time
                          description: When the request was created
                          example: '2024-02-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When the request was last updated
                          example: '2024-02-15T10:00:00Z'
                      required:
                      - id
                      - user_id
                      - leave_type
                      - start_date
                      - end_date
                      - hours_calculated
                      - status
                      - created_at
                      - updated_at
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Leave Management
      summary: Create a new leave request
      description: |
        Create a new leave request for the authenticated user.
        The request will be validated against business rules, blackout periods, and coverage limits.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - leave_type_id
              - start_date
              - end_date
              properties:
                leave_type_id:
                  type: integer
                  description: ID of the leave type
                  example: 1
                start_date:
                  type: string
                  format: date
                  description: Start date of leave
                  example: '2024-03-15'
                end_date:
                  type: string
                  format: date
                  description: End date of leave
                  example: '2024-03-17'
                notes:
                  type: string
                  description: Optional notes for the leave request
                  example: Family vacation
                half_day:
                  type: boolean
                  description: Whether this is a half-day request
                  example: false
      responses:
        '201':
          description: Leave request created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/leave_requests/{id}":
    get:
      tags:
      - Leave Management
      summary: Get a specific leave request
      description: Get detailed information about a specific leave request
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Leave request retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Leave Management
      summary: Update a leave request
      description: 'Update a pending leave request. Only pending requests can be modified.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                leave_type_id:
                  type: integer
                  description: ID of the leave type
                  example: 1
                start_date:
                  type: string
                  format: date
                  description: Start date of leave
                  example: '2024-03-15'
                end_date:
                  type: string
                  format: date
                  description: End date of leave
                  example: '2024-03-17'
                notes:
                  type: string
                  description: Optional notes for the leave request
                  example: Updated vacation dates
                half_day:
                  type: boolean
                  description: Whether this is a half-day request
                  example: false
      responses:
        '200':
          description: Leave request updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
        '403':
          description: Leave request cannot be modified
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Leave Management
      summary: Delete a leave request
      description: Delete a leave request. Only pending requests can be deleted.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Leave request deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Leave request deleted successfully
        '403':
          description: Leave request cannot be deleted
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests/{id}/cancel":
    post:
      tags:
      - Leave Management
      summary: Cancel a leave request
      description: Cancel a leave request. This action is different from delete and
        may restore leave balances.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Leave request cancelled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
                  message:
                    type: string
                    example: Leave request cancelled successfully
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests/conflicts":
    get:
      tags:
      - Leave Management
      summary: Check for shift conflicts
      description: |
        Check if a proposed leave request would conflict with assigned shifts.
        This is typically used before creating a leave request to warn users.
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        required: true
        description: Proposed start date
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: end_date
        in: query
        required: true
        description: Proposed end date
        schema:
          type: string
          format: date
          example: '2024-03-17'
      responses:
        '200':
          description: Conflict check completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  conflicts:
                    type: array
                    items:
                      type: object
                      description: Information about a conflicting shift
                      properties:
                        id:
                          type: integer
                          description: Shift ID
                          example: 789
                        name:
                          type: string
                          description: Shift name
                          example: Morning Shift
                        start_time:
                          type: string
                          format: date-time
                          description: Shift start time
                          example: '2024-03-15T08:00:00Z'
                        end_time:
                          type: string
                          format: date-time
                          description: Shift end time
                          example: '2024-03-15T16:00:00Z'
                        location:
                          type: object
                          nullable: true
                          description: Shift location
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Main Office
                      required:
                      - id
                      - name
                      - start_time
                      - end_time
                  has_conflicts:
                    type: boolean
                    example: false
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests/history":
    get:
      tags:
      - Leave Management
      summary: Get leave request history with status changes
      description: Get detailed history of leave requests including all status changes
        and approval workflow.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: start_date
        in: query
        description: 'Filter history from this date (default: 12 months ago)'
        schema:
          type: string
          format: date
          example: '2023-03-01'
      - name: end_date
        in: query
        description: 'Filter history until this date (default: today)'
        schema:
          type: string
          format: date
          example: '2024-03-01'
      responses:
        '200':
          description: Leave request history retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaveRequestDetailed"
                      - type: object
                        properties:
                          status_history:
                            type: array
                            description: Complete status change history
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 1
                                from_status:
                                  type: string
                                  example: pending
                                to_status:
                                  type: string
                                  example: approved
                                changed_by:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                      example: 2
                                    name:
                                      type: string
                                      example: Manager Name
                                notes:
                                  type: string
                                  nullable: true
                                  example: Approved for vacation
                                changed_at:
                                  type: string
                                  format: date-time
                                  example: '2024-02-16T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
  "/leave_requests/analytics":
    get:
      tags:
      - Leave Management
      summary: Get leave usage analytics
      description: Get comprehensive analytics about leave usage patterns, trends,
        and statistics.
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        description: 'Analytics period start date (default: beginning of year)'
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: 'Analytics period end date (default: end of year)'
        schema:
          type: string
          format: date
          example: '2024-12-31'
      responses:
        '200':
          description: Leave analytics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Comprehensive leave usage analytics
                properties:
                  period:
                    type: object
                    description: Analytics period
                    properties:
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-01'
                      end_date:
                        type: string
                        format: date
                        example: '2024-12-31'
                      total_days:
                        type: integer
                        example: 366
                  summary:
                    type: object
                    description: Overall summary statistics
                    properties:
                      total_requests:
                        type: integer
                        example: 25
                      total_leave_days:
                        type: integer
                        example: 75
                      approved_requests:
                        type: integer
                        example: 20
                      approved_days:
                        type: integer
                        example: 60
                      pending_requests:
                        type: integer
                        example: 3
                      denied_requests:
                        type: integer
                        example: 1
                      cancelled_requests:
                        type: integer
                        example: 1
                  by_leave_type:
                    type: array
                    description: Analytics grouped by leave type
                    items:
                      type: object
                      properties:
                        leave_type:
                          "$ref": "#/components/schemas/LeaveTypeBasic"
                        total_requests:
                          type: integer
                          example: 15
                        total_days:
                          type: integer
                          example: 45
                        approved_requests:
                          type: integer
                          example: 12
                        approved_days:
                          type: integer
                          example: 36
                        pending_requests:
                          type: integer
                          example: 2
                        denied_requests:
                          type: integer
                          example: 1
                        cancelled_requests:
                          type: integer
                          example: 0
                  by_month:
                    type: array
                    description: Analytics grouped by month
                    items:
                      type: object
                      properties:
                        month:
                          type: string
                          example: 2024-03
                        month_name:
                          type: string
                          example: March 2024
                        total_requests:
                          type: integer
                          example: 5
                        total_days:
                          type: integer
                          example: 15
                        approved_days:
                          type: integer
                          example: 12
                  by_status:
                    type: array
                    description: Analytics grouped by status
                    items:
                      type: object
                      properties:
                        status:
                          type: string
                          example: approved
                        count:
                          type: integer
                          example: 20
                        total_days:
                          type: integer
                          example: 60
                        percentage:
                          type: number
                          example: 80.0
                  usage_patterns:
                    type: object
                    description: Usage patterns and insights
                    properties:
                      most_used_leave_type:
                        type: object
                        nullable: true
                        description: Leave type with most usage
                      busiest_month:
                        type: object
                        nullable: true
                        description: Month with most leave days
                      average_request_length:
                        type: number
                        description: Average length of leave requests in days
                        example: 3.0
                      longest_request:
                        type: object
                        nullable: true
                        description: Longest leave request
                      approval_rate:
                        type: number
                        description: Percentage of requests approved
                        example: 80.0
                required:
                - period
                - summary
                - by_leave_type
                - by_month
                - by_status
                - usage_patterns
  "/leave_requests/bulk":
    post:
      tags:
      - Leave Management
      summary: Create multiple leave requests
      description: Create multiple leave requests in a single operation. Maximum 10
        requests per batch.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - leave_requests
              properties:
                leave_requests:
                  type: array
                  maxItems: 10
                  items:
                    type: object
                    required:
                    - leave_type_id
                    - start_date
                    - end_date
                    properties:
                      leave_type_id:
                        type: integer
                        example: 1
                      start_date:
                        type: string
                        format: date
                        example: '2024-03-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-03-17'
                      notes:
                        type: string
                        example: Vacation
                      half_day:
                        type: boolean
                        example: false
      responses:
        '201':
          description: Bulk leave requests processed
          content:
            application/json:
              schema:
                type: object
                description: Response from bulk leave request operation
                properties:
                  results:
                    type: array
                    description: Successful requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 0
                        success:
                          type: boolean
                          example: true
                        leave_request:
                          "$ref": "#/components/schemas/LeaveRequestDetailed"
                  errors:
                    type: array
                    description: Failed requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 1
                        success:
                          type: boolean
                          example: false
                        errors:
                          type: array
                          items:
                            type: string
                          example:
                          - Start date cannot be in the past
                  summary:
                    type: object
                    description: Summary of bulk operation
                    properties:
                      total_requests:
                        type: integer
                        example: 5
                      successful:
                        type: integer
                        example: 4
                      failed:
                        type: integer
                        example: 1
                required:
                - results
                - errors
                - summary
        '422':
          description: Some or all requests failed validation
          content:
            application/json:
              schema:
                type: object
                description: Response from bulk leave request operation
                properties:
                  results:
                    type: array
                    description: Successful requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 0
                        success:
                          type: boolean
                          example: true
                        leave_request:
                          "$ref": "#/components/schemas/LeaveRequestDetailed"
                  errors:
                    type: array
                    description: Failed requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 1
                        success:
                          type: boolean
                          example: false
                        errors:
                          type: array
                          items:
                            type: string
                          example:
                          - Start date cannot be in the past
                  summary:
                    type: object
                    description: Summary of bulk operation
                    properties:
                      total_requests:
                        type: integer
                        example: 5
                      successful:
                        type: integer
                        example: 4
                      failed:
                        type: integer
                        example: 1
                required:
                - results
                - errors
                - summary
  "/leave_balances":
    get:
      tags:
      - Leave Management
      summary: Get user's leave balances
      description: |
        Get leave balances for all leave types available to the user.
        Includes accrued, used, available amounts and usage percentages.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Leave balances retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Leave balance information for a specific leave
                        type. All numeric values are in HOURS.
                      properties:
                        leave_type_id:
                          type: integer
                          description: ID of the leave type
                          example: 1
                        leave_type:
                          "$ref": "#/components/schemas/LeaveTypeBasic"
                        year:
                          type: integer
                          description: Year for this balance
                          example: 2024
                        unit:
                          type: string
                          description: Unit of measurement for balance values
                          example: hours
                          enum:
                          - hours
                        accrued:
                          type: number
                          description: Total hours accrued for the year
                          example: 120.0
                        used:
                          type: number
                          description: Hours already used
                          example: 40.0
                        balance:
                          type: number
                          description: Available hours remaining
                          example: 80.0
                        pending:
                          type: number
                          description: Hours in pending requests
                          example: 24.0
                        percentages:
                          type: object
                          description: Usage percentages for progress indicators
                          properties:
                            used:
                              type: number
                              description: Percentage of accrued hours used
                              example: 33.3
                            pending:
                              type: number
                              description: Percentage of accrued hours pending
                              example: 20.0
                            available:
                              type: number
                              description: Percentage of accrued hours available
                              example: 46.7
                      required:
                      - leave_type_id
                      - leave_type
                      - year
                      - accrued
                      - used
                      - balance
                      - pending
                      - percentages
  "/leave_balances/{leave_type_id}":
    get:
      tags:
      - Leave Management
      summary: Get balance for specific leave type
      description: Get detailed balance information for a specific leave type
      security:
      - BearerAuth: []
      parameters:
      - name: leave_type_id
        in: path
        required: true
        description: Leave type ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Leave balance retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_balance:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveBalance"
                    - type: object
                      properties:
                        recent_usage:
                          type: array
                          description: Leave consumed against this balance in the
                            last 6 months, newest first. Includes approved leave requests,
                            usage an administrator recorded by hand, and hours paid
                            out from banked time — all three move the balance's `used`
                            figure. Pending requests are NOT included (they are not
                            usage yet); read the balance's `pending` hours for those.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                description: Identifier within the table named by
                                  `record_type`.
                                example: 123
                              record_type:
                                type: string
                                description: Which record `id` refers to. `leave_request`
                                  for an approved request; `leave_ledger_entry` for
                                  administrator- recorded usage or a banked-time payout.
                                enum:
                                - leave_request
                                - leave_ledger_entry
                                example: leave_request
                              source:
                                type: string
                                enum:
                                - leave_request
                                - manual
                                - payout
                                example: leave_request
                              start_date:
                                type: string
                                format: date
                                example: '2024-01-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-01-17'
                              hours:
                                type: number
                                description: Hours actually charged to the balance
                                  (the paid portion). Use this figure to reconcile
                                  against `used`.
                                example: 16
                              unpaid_hours:
                                type: number
                                description: Portion taken as unpaid leave, which
                                  never touches the balance.
                                example: 8
                              hours_calculated:
                                type: number
                                description: Total hours on the request, including
                                  any unpaid portion. Equals `hours` unless the request
                                  has an unpaid split.
                                example: 24
                              business_days:
                                type: number
                                description: 'DEPRECATED: Use hours_calculated'
                                example: 24
                              status:
                                type: string
                                nullable: true
                                description: Always `approved` for a leave request;
                                  null for ledger entries.
                                example: approved
                              upcoming:
                                type: boolean
                                description: Approved but the time off has not happened
                                  yet.
                                example: false
                              counts_toward_balance:
                                type: boolean
                                description: False for a historical record imported
                                  alongside the balance figures its hours are already
                                  inside. Such rows are listed but excluded from `used`.
                                example: true
                              notes:
                                type: string
                                nullable: true
                                example: Vacation
                              edited:
                                type: boolean
                                description: The leave request has been modified since
                                  it was submitted.
                                example: false
                              edit_count:
                                type: integer
                                example: 0
                              last_edited_at:
                                type: string
                                format: date-time
                                nullable: true
                                example: '2024-02-01T09:30:00Z'
                        accrual_policy:
                          type: object
                          nullable: true
                          description: Accrual policy information
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Standard Vacation Policy
                            accrual_rate:
                              type: number
                              description: Hours accrued per period
                              example: 3.08
                            accrual_unit:
                              type: string
                              description: Unit for all accrual values
                              example: hours
                              enum:
                              - hours
                            accrual_frequency:
                              type: string
                              example: bi-weekly
                            max_balance:
                              type: number
                              description: Maximum balance cap in hours
                              nullable: true
                              example: 120.0
                            max_accrual:
                              type: number
                              description: Deprecated, use max_balance
                              nullable: true
                              example: 120.0
                            carryover_allowed:
                              type: boolean
                              example: true
                            carryover_max:
                              type: number
                              description: Maximum hours to carry over
                              nullable: true
                              example: 40.0
                            waiting_period_days:
                              type: integer
                              description: Calendar days before accrual starts
                              example: 90
                            proration_enabled:
                              type: boolean
                              example: true
                        projected_balance:
                          type: number
                          description: Projected balance (hours) at end of year
                          example: 100.0
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_balances/summary":
    get:
      tags:
      - Leave Management
      summary: Get leave balance summary
      description: |
        Get a comprehensive summary of all leave balances including totals,
        upcoming leave, and usage analytics.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Leave balance summary retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Comprehensive leave balance summary
                properties:
                  totals:
                    type: object
                    description: Totals across all leave types
                    properties:
                      accrued:
                        type: number
                        example: 25.0
                      used:
                        type: number
                        example: 8.0
                      pending:
                        type: number
                        example: 3.0
                      available:
                        type: number
                        example: 14.0
                      usage_percentage:
                        type: number
                        example: 32.0
                  by_leave_type:
                    type: array
                    description: Balances by leave type
                    items:
                      "$ref": "#/components/schemas/LeaveBalance"
                  upcoming_leave:
                    type: array
                    description: Upcoming approved leave
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        leave_type:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Vacation
                            color:
                              type: string
                              example: "#4CAF50"
                        start_date:
                          type: string
                          format: date
                          example: '2024-04-15'
                        end_date:
                          type: string
                          format: date
                          example: '2024-04-17'
                        hours:
                          type: number
                          description: Hours that will be charged to the balance (the
                            paid portion).
                          example: 16
                        unpaid_hours:
                          type: number
                          description: Portion to be taken as unpaid leave, which
                            never touches the balance.
                          example: 8
                        hours_calculated:
                          type: number
                          description: Total hours on the request, including any unpaid
                            portion. Equals `hours` unless the request has an unpaid
                            split.
                          example: 24
                        business_days:
                          type: number
                          description: 'DEPRECATED: Use hours_calculated'
                          example: 24
                        notes:
                          type: string
                          nullable: true
                          example: Spring vacation
                  year:
                    type: integer
                    description: Year for this summary
                    example: 2024
                required:
                - totals
                - by_leave_type
                - upcoming_leave
                - year
  "/leave_types":
    get:
      tags:
      - Leave Management
      summary: List available leave types
      description: |
        Get all active leave types available to the user's business.
        Optionally include current balance information.
      security:
      - BearerAuth: []
      parameters:
      - name: include_balances
        in: query
        description: Include current balance information
        schema:
          type: boolean
          default: false
          example: true
      responses:
        '200':
          description: Leave types retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Leave type information
                      properties:
                        id:
                          type: integer
                          description: Unique leave type ID
                          example: 1
                        name:
                          type: string
                          description: Name of the leave type
                          example: Vacation
                        color:
                          type: string
                          description: Color code for UI display
                          example: "#4CAF50"
                        icon:
                          type: string
                          description: Icon class for UI display
                          example: fas fa-calendar-star
                        active:
                          type: boolean
                          description: Whether this leave type is active
                          example: true
                        default:
                          type: boolean
                          description: Whether this is the default leave type
                          example: false
                        current_balance:
                          type: object
                          nullable: true
                          description: Current balance for this leave type (if requested)
                          properties:
                            accrued:
                              type: number
                              example: 15.0
                            used:
                              type: number
                              example: 5.0
                            balance:
                              type: number
                              example: 10.0
                            year:
                              type: integer
                              example: 2024
                      required:
                      - id
                      - name
                      - color
                      - icon
                      - active
                      - default
  "/leave_types/{id}":
    get:
      tags:
      - Leave Management
      summary: Get specific leave type details
      description: Get detailed information about a specific leave type including
        policies and rules
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave type ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Leave type retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_type:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveType"
                    - type: object
                      properties:
                        description:
                          type: string
                          nullable: true
                          description: Description of the leave type
                          example: Annual vacation leave for rest and recreation
                        advance_notice_days:
                          type: integer
                          description: Required advance notice in days
                          example: 7
                        documentation_required:
                          type: boolean
                          description: Whether documentation is required
                          example: false
                        documentation_threshold_days:
                          type: integer
                          description: Threshold for requiring documentation
                          example: 3
                        requires_approval:
                          type: boolean
                          description: Whether requests require approval
                          example: true
                        deduct_from_balance:
                          type: boolean
                          description: Whether to deduct from balance
                          example: true
                        allow_negative_balance:
                          type: boolean
                          description: Whether negative balances are allowed
                          example: false
                        accrual_enabled:
                          type: boolean
                          description: Whether accrual is enabled
                          example: true
                        accrual_policy:
                          type: object
                          nullable: true
                          description: Associated accrual policy
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Standard Vacation Policy
                            accrual_rate:
                              type: number
                              example: 1.25
                            accrual_frequency:
                              type: string
                              example: monthly
                            accrual_frequency_display:
                              type: string
                              example: Monthly
                            max_accrual:
                              type: number
                              nullable: true
                              example: 30.0
                            carryover_allowed:
                              type: boolean
                              example: true
                            carryover_max:
                              type: number
                              nullable: true
                              example: 5.0
                            waiting_period_days:
                              type: integer
                              example: 90
                            proration_enabled:
                              type: boolean
                              example: true
                        business_rules:
                          type: object
                          description: Business rules and policies
                          properties:
                            advance_notice_required:
                              type: boolean
                              example: true
                            documentation_rules:
                              type: object
                              properties:
                                required:
                                  type: boolean
                                  example: false
                                threshold_days:
                                  type: integer
                                  example: 3
                            approval_workflow:
                              type: object
                              properties:
                                requires_approval:
                                  type: boolean
                                  example: true
                                auto_approve_threshold:
                                  type: number
                                  nullable: true
                                  example:
                            balance_rules:
                              type: object
                              properties:
                                deduct_from_balance:
                                  type: boolean
                                  example: true
                                allow_negative:
                                  type: boolean
                                  example: false
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/holidays":
    get:
      tags:
      - Leave Management
      summary: List business holidays
      description: 'Get all holidays for the business with optional filtering by year,
        date range, or upcoming holidays.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: query
        description: Filter holidays by year
        schema:
          type: integer
          example: 2024
      - name: start_date
        in: query
        description: Filter holidays from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter holidays until this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: upcoming
        in: query
        description: Only show upcoming holidays
        schema:
          type: boolean
          example: true
      responses:
        '200':
          description: Holidays retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Business holiday information
                      properties:
                        id:
                          type: integer
                          description: Unique holiday ID
                          example: 1
                        name:
                          type: string
                          description: Holiday name
                          example: New Year's Day
                        date:
                          type: string
                          format: date
                          description: Holiday date
                          example: '2024-01-01'
                        description:
                          type: string
                          nullable: true
                          description: Holiday description
                          example: New Year's Day celebration
                        recurring:
                          type: boolean
                          description: Whether this holiday recurs annually
                          example: true
                        business_id:
                          type: integer
                          description: Business ID
                          example: 1
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                      required:
                      - id
                      - name
                      - date
                      - business_id
                      - created_at
                      - updated_at
  "/holidays/{year}":
    get:
      tags:
      - Leave Management
      summary: Get holidays for specific year
      description: Get all holidays for a specific year
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: path
        required: true
        description: Year to get holidays for
        schema:
          type: integer
          example: 2024
      responses:
        '200':
          description: Holidays for year retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:
                    type: integer
                    example: 2024
                  holidays:
                    type: array
                    items:
                      type: object
                      description: Business holiday information
                      properties:
                        id:
                          type: integer
                          description: Unique holiday ID
                          example: 1
                        name:
                          type: string
                          description: Holiday name
                          example: New Year's Day
                        date:
                          type: string
                          format: date
                          description: Holiday date
                          example: '2024-01-01'
                        description:
                          type: string
                          nullable: true
                          description: Holiday description
                          example: New Year's Day celebration
                        recurring:
                          type: boolean
                          description: Whether this holiday recurs annually
                          example: true
                        business_id:
                          type: integer
                          description: Business ID
                          example: 1
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                      required:
                      - id
                      - name
                      - date
                      - business_id
                      - created_at
                      - updated_at
                  total_count:
                    type: integer
                    example: 12
  "/holidays/{id}":
    get:
      tags:
      - Leave Management
      summary: Get specific holiday details
      description: Get detailed information about a specific holiday
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Holiday ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Holiday retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  holiday:
                    allOf:
                    - "$ref": "#/components/schemas/Holiday"
                    - type: object
                      properties:
                        day_of_week:
                          type: string
                          description: Day of the week
                          example: Monday
                        week_of_year:
                          type: integer
                          description: Week number of the year
                          example: 1
                        month_name:
                          type: string
                          description: Month name
                          example: January
                        is_weekend:
                          type: boolean
                          description: Whether the holiday falls on a weekend
                          example: false
                        days_until:
                          type: integer
                          nullable: true
                          description: Days until this holiday (null if past)
                          example: 30
                        metadata:
                          type: object
                          description: Additional holiday metadata
                          properties:
                            year:
                              type: integer
                              example: 2024
                            month:
                              type: integer
                              example: 1
                            day:
                              type: integer
                              example: 1
                            quarter:
                              type: integer
                              example: 1
                            is_past:
                              type: boolean
                              example: false
                            is_today:
                              type: boolean
                              example: false
                            is_future:
                              type: boolean
                              example: true
  "/blackout_periods":
    get:
      tags:
      - Leave Management
      summary: List blackout periods
      description: 'Get blackout periods with optional filtering by status, date range,
        leave type, or location.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - active
          - upcoming
          - past
          example: active
      - name: start_date
        in: query
        description: Filter periods overlapping from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter periods overlapping until this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: leave_type_id
        in: query
        description: Filter by leave type
        schema:
          type: integer
          example: 1
      - name: location_id
        in: query
        description: Filter by location
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Blackout periods retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Leave blackout period information
                      properties:
                        id:
                          type: integer
                          description: Unique blackout period ID
                          example: 1
                        name:
                          type: string
                          description: Blackout period name
                          example: Winter Holiday Season
                        description:
                          type: string
                          nullable: true
                          description: Blackout period description
                          example: No leave allowed during winter holidays
                        start_date:
                          type: string
                          format: date
                          description: Start date of blackout period
                          example: '2024-12-15'
                        end_date:
                          type: string
                          format: date
                          description: End date of blackout period
                          example: '2024-12-31'
                        block_requests:
                          type: boolean
                          description: Whether requests are completely blocked or
                            require special approval
                          example: true
                        status:
                          type: string
                          description: Current status of the blackout period
                          example: upcoming
                        duration_days:
                          type: integer
                          description: Duration in days
                          example: 17
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                      required:
                      - id
                      - name
                      - start_date
                      - end_date
                      - block_requests
                      - status
                      - duration_days
  "/blackout_periods/upcoming":
    get:
      tags:
      - Leave Management
      summary: Get upcoming blackout periods
      description: Get upcoming blackout periods with optional limit
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        description: Maximum number of periods to return
        schema:
          type: integer
          default: 10
          example: 5
      responses:
        '200':
          description: Upcoming blackout periods retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  upcoming_blackouts:
                    type: array
                    items:
                      type: object
                      description: Leave blackout period information
                      properties:
                        id:
                          type: integer
                          description: Unique blackout period ID
                          example: 1
                        name:
                          type: string
                          description: Blackout period name
                          example: Winter Holiday Season
                        description:
                          type: string
                          nullable: true
                          description: Blackout period description
                          example: No leave allowed during winter holidays
                        start_date:
                          type: string
                          format: date
                          description: Start date of blackout period
                          example: '2024-12-15'
                        end_date:
                          type: string
                          format: date
                          description: End date of blackout period
                          example: '2024-12-31'
                        block_requests:
                          type: boolean
                          description: Whether requests are completely blocked or
                            require special approval
                          example: true
                        status:
                          type: string
                          description: Current status of the blackout period
                          example: upcoming
                        duration_days:
                          type: integer
                          description: Duration in days
                          example: 17
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                      required:
                      - id
                      - name
                      - start_date
                      - end_date
                      - block_requests
                      - status
                      - duration_days
                  total_count:
                    type: integer
                    example: 3
  "/blackout_periods/current":
    get:
      tags:
      - Leave Management
      summary: Get current active blackout periods
      description: Get all currently active blackout periods
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current blackout periods retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_blackouts:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/BlackoutPeriod"
                      - type: object
                        properties:
                          leave_types:
                            type: array
                            description: Leave types affected by this blackout
                            items:
                              "$ref": "#/components/schemas/LeaveTypeBasic"
                          locations:
                            type: array
                            description: Locations affected by this blackout
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 1
                                name:
                                  type: string
                                  example: Main Office
                                address:
                                  type: string
                                  example: 123 Main St
                          applies_to_all_leave_types:
                            type: boolean
                            description: Whether this applies to all leave types
                            example: true
                          applies_to_all_locations:
                            type: boolean
                            description: Whether this applies to all locations
                            example: true
                          days_until_start:
                            type: integer
                            nullable: true
                            description: Days until blackout starts (null if past)
                            example: 30
                          days_until_end:
                            type: integer
                            nullable: true
                            description: Days until blackout ends (null if past)
                            example: 47
                          is_current:
                            type: boolean
                            description: Whether this blackout is currently active
                            example: false
                          is_upcoming:
                            type: boolean
                            description: Whether this blackout is upcoming
                            example: true
                          is_past:
                            type: boolean
                            description: Whether this blackout is in the past
                            example: false
                          created_by:
                            type: object
                            nullable: true
                            description: User who created this blackout period
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Admin User
                          impact_message:
                            type: string
                            description: Message describing the impact of this blackout
                            example: Leave requests are completely blocked during
                              this period
                  total_count:
                    type: integer
                    example: 1
                  has_active_blackouts:
                    type: boolean
                    example: true
  "/blackout_periods/{id}":
    get:
      tags:
      - Leave Management
      summary: Get specific blackout period details
      description: Get detailed information about a specific blackout period
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Blackout period ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Blackout period retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  blackout_period:
                    allOf:
                    - "$ref": "#/components/schemas/BlackoutPeriod"
                    - type: object
                      properties:
                        leave_types:
                          type: array
                          description: Leave types affected by this blackout
                          items:
                            "$ref": "#/components/schemas/LeaveTypeBasic"
                        locations:
                          type: array
                          description: Locations affected by this blackout
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Main Office
                              address:
                                type: string
                                example: 123 Main St
                        applies_to_all_leave_types:
                          type: boolean
                          description: Whether this applies to all leave types
                          example: true
                        applies_to_all_locations:
                          type: boolean
                          description: Whether this applies to all locations
                          example: true
                        days_until_start:
                          type: integer
                          nullable: true
                          description: Days until blackout starts (null if past)
                          example: 30
                        days_until_end:
                          type: integer
                          nullable: true
                          description: Days until blackout ends (null if past)
                          example: 47
                        is_current:
                          type: boolean
                          description: Whether this blackout is currently active
                          example: false
                        is_upcoming:
                          type: boolean
                          description: Whether this blackout is upcoming
                          example: true
                        is_past:
                          type: boolean
                          description: Whether this blackout is in the past
                          example: false
                        created_by:
                          type: object
                          nullable: true
                          description: User who created this blackout period
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Admin User
                        impact_message:
                          type: string
                          description: Message describing the impact of this blackout
                          example: Leave requests are completely blocked during this
                            period
  "/leave_coverage/availability":
    get:
      tags:
      - Leave Management
      summary: Check leave availability for specific date
      description: 'Check if leave is available for a specific date, considering coverage
        limits and business rules.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: date
        in: query
        required: true
        description: Date to check availability for
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      - name: leave_type_id
        in: query
        description: Leave type to check
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Availability check completed
          content:
            application/json:
              schema:
                type: object
                description: Leave availability check result
                properties:
                  date:
                    type: string
                    format: date
                    description: Date checked
                    example: '2024-03-15'
                  available:
                    type: boolean
                    description: Whether leave is available
                    example: true
                  unlimited:
                    type: boolean
                    description: Whether coverage is unlimited
                    example: false
                  current_off:
                    type: integer
                    description: Number of people currently off
                    example: 2
                  max_allowed:
                    type: integer
                    nullable: true
                    description: Maximum people allowed off (null if unlimited)
                    example: 5
                  available_spots:
                    type: integer
                    nullable: true
                    description: Available spots remaining (null if unlimited)
                    example: 3
                  limit_type:
                    type: string
                    description: Type of coverage limit
                    example: percentage
                  message:
                    type: string
                    description: Human-readable availability message
                    example: 3 spots available out of 5 maximum
                  metadata:
                    type: object
                    description: Additional date metadata
                    properties:
                      day_of_week:
                        type: string
                        example: Friday
                      is_weekend:
                        type: boolean
                        example: false
                      is_holiday:
                        type: boolean
                        example: false
                      is_blackout_period:
                        type: boolean
                        example: false
                required:
                - date
                - available
                - unlimited
                - current_off
                - message
                - metadata
  "/leave_coverage/date_range":
    get:
      tags:
      - Leave Management
      summary: Check availability for date range
      description: 'Check leave availability for a range of dates with summary statistics.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        required: true
        description: Start date of range
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: end_date
        in: query
        required: true
        description: End date of range
        schema:
          type: string
          format: date
          example: '2024-03-17'
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      - name: leave_type_id
        in: query
        description: Leave type to check
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Date range availability check completed
          content:
            application/json:
              schema:
                type: object
                description: Leave coverage for date range
                properties:
                  start_date:
                    type: string
                    format: date
                    example: '2024-03-15'
                  end_date:
                    type: string
                    format: date
                    example: '2024-03-17'
                  total_days:
                    type: integer
                    description: Total days in range
                    example: 3
                  coverage_data:
                    type: array
                    description: Coverage data for each day
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaveCoverageAvailability"
                      - type: object
                        properties:
                          week_of_year:
                            type: integer
                            example: 11
                          month:
                            type: integer
                            example: 3
                  summary:
                    type: object
                    description: Summary statistics for the range
                    properties:
                      total_days:
                        type: integer
                        example: 3
                      available_days:
                        type: integer
                        example: 2
                      blocked_days:
                        type: integer
                        example: 1
                      weekend_days:
                        type: integer
                        example: 0
                      holiday_days:
                        type: integer
                        example: 0
                      blackout_days:
                        type: integer
                        example: 1
                      availability_percentage:
                        type: number
                        example: 66.7
                required:
                - start_date
                - end_date
                - total_days
                - coverage_data
                - summary
  "/leave_coverage/alternatives":
    get:
      tags:
      - Leave Management
      summary: Find alternative dates for leave request
      description: 'Find alternative date ranges when the requested dates are not
        available.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        required: true
        description: Original start date
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: end_date
        in: query
        required: true
        description: Original end date
        schema:
          type: string
          format: date
          example: '2024-03-17'
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      - name: leave_type_id
        in: query
        description: Leave type to check
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Alternative dates found
          content:
            application/json:
              schema:
                type: object
                description: Alternative date suggestions
                properties:
                  original_request:
                    type: object
                    description: Original requested dates
                    properties:
                      start_date:
                        type: string
                        format: date
                        example: '2024-03-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-03-17'
                      duration_days:
                        type: integer
                        example: 3
                  alternatives:
                    type: array
                    description: Alternative date ranges
                    items:
                      type: object
                      properties:
                        start_date:
                          type: string
                          format: date
                          example: '2024-03-22'
                        end_date:
                          type: string
                          format: date
                          example: '2024-03-24'
                        duration_days:
                          type: integer
                          example: 3
                        days_from_original:
                          type: integer
                          description: Days difference from original start date
                          example: 7
                        has_weekends:
                          type: boolean
                          example: true
                        has_holidays:
                          type: boolean
                          example: false
                        business_days:
                          type: integer
                          example: 3
                  total_alternatives:
                    type: integer
                    description: Number of alternatives found
                    example: 5
                  search_period:
                    type: object
                    description: Period searched for alternatives
                    properties:
                      start_date:
                        type: string
                        format: date
                        example: '2024-03-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-05-14'
                required:
                - original_request
                - alternatives
                - total_alternatives
                - search_period
  "/leave_coverage/calendar/{year}/{month}":
    get:
      tags:
      - Leave Management
      summary: Get calendar view of leave coverage
      description: 'Get a monthly calendar view showing leave availability, holidays,
        and blackout periods.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: path
        required: true
        description: Year for calendar
        schema:
          type: integer
          example: 2024
      - name: month
        in: path
        required: true
        description: Month for calendar (1-12)
        schema:
          type: integer
          minimum: 1
          maximum: 12
          example: 3
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Calendar data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Monthly calendar view of leave coverage
                properties:
                  year:
                    type: integer
                    example: 2024
                  month:
                    type: integer
                    example: 3
                  month_name:
                    type: string
                    example: March
                  days_in_month:
                    type: integer
                    example: 31
                  calendar_data:
                    type: array
                    description: Data for each day of the month
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaveCoverageAvailability"
                      - type: object
                        properties:
                          day_of_month:
                            type: integer
                            example: 15
                          day_of_week_short:
                            type: string
                            example: Fri
                          is_today:
                            type: boolean
                            example: false
                          holiday:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Good Friday
                              description:
                                type: string
                                example: Christian holiday
                          blackout_period:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Spring Break
                              blocks_requests:
                                type: boolean
                                example: true
                          week_of_month:
                            type: integer
                            example: 3
                  summary:
                    type: object
                    description: Monthly summary statistics
                    properties:
                      total_days:
                        type: integer
                        example: 31
                      available_days:
                        type: integer
                        example: 25
                      blocked_days:
                        type: integer
                        example: 6
                      weekend_days:
                        type: integer
                        example: 8
                      holiday_days:
                        type: integer
                        example: 2
                      blackout_days:
                        type: integer
                        example: 4
                required:
                - year
                - month
                - month_name
                - days_in_month
                - calendar_data
                - summary
  "/user_availabilities":
    get:
      tags:
      - Availability
      summary: List user availabilities and leave blocks
      description: "Get a list of user availability blocks and leave requests for
        a specific week. \nReturns availability data along with leave blocks (approved
        and pending), week \nconfirmation status, and calendar settings. This matches
        the web UI display \nwhere both availability and leave blocks are shown together.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: Week start date (defaults to current week)
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Availabilities and leaves retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availabilities:
                    type: array
                    description: User availability blocks for the week
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
                  leaves:
                    type: array
                    description: Leave requests (approved and pending) for the week
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          description: Leave request ID
                          example: 123
                        start_date:
                          type: string
                          format: date
                          description: Leave start date
                          example: '2025-10-15'
                        end_date:
                          type: string
                          format: date
                          description: Leave end date
                          example: '2025-10-15'
                        leave_type:
                          type: string
                          description: Name of the leave type
                          example: Vacation
                        status:
                          type: string
                          description: Leave request status
                          enum:
                          - approved
                          - pending
                          example: approved
                        reason:
                          type: string
                          description: Reason for leave
                          example: Personal time off
                  week_start:
                    type: string
                    format: date
                    example: '2025-10-13'
                  week_end:
                    type: string
                    format: date
                    example: '2025-10-19'
                  week_confirmed:
                    type: boolean
                    example: false
                  calendar_settings:
                    type: object
                    properties:
                      start_time:
                        type: integer
                        example: 6
                      end_time:
                        type: integer
                        example: 21
                      minimum_block_minutes:
                        type: integer
                        example: 60
                  total_count:
                    type: integer
                    description: Total count of availability blocks
                    example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Availability
      summary: Create user availability
      description: |
        Create a new availability block for a specific date and time range.
        Date must be editable (not in past or confirmed week).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - specific_date
              - start_time
              - end_time
              properties:
                specific_date:
                  type: string
                  format: date
                  example: '2025-10-15'
                start_time:
                  type: string
                  format: time
                  example: '09:00'
                end_time:
                  type: string
                  format: time
                  example: '17:00'
                notes:
                  type: string
                  example: Available for morning shift
      responses:
        '201':
          description: Availability created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availability:
                    type: object
                    description: User availability block information
                    properties:
                      id:
                        type: integer
                        description: Unique availability ID
                        example: 123
                      specific_date:
                        type: string
                        format: date
                        description: Date for this availability block
                        example: '2025-10-15'
                      start_time:
                        type: string
                        format: time
                        description: Start time (displayed in user's timezone)
                        example: '09:00'
                      end_time:
                        type: string
                        format: time
                        description: End time (displayed in user's timezone)
                        example: '17:00'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about this availability
                        example: Available for morning shift
                      store_in_utc:
                        type: boolean
                        description: Whether times are stored in UTC
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: When this availability was created
                        example: '2025-10-01T10:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this availability was last updated
                        example: '2025-10-01T10:30:00Z'
                    required:
                    - id
                    - specific_date
                    - start_time
                    - end_time
                  message:
                    type: string
                    example: Availability created successfully
        '422':
          description: Validation error or duplicate availability
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/weekly":
    get:
      tags:
      - Availability
      summary: Get weekly availability summary
      description: "Get a comprehensive weekly view including availabilities, leaves,
        \nconfirmation status, and total hours for a specific week.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: Week start date (defaults to current week)
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Weekly availability retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  week_start:
                    type: string
                    format: date
                    example: '2025-10-13'
                  week_end:
                    type: string
                    format: date
                    example: '2025-10-19'
                  availabilities:
                    type: array
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
                  leaves:
                    type: array
                    items:
                      type: object
                      description: Basic leave request information
                      properties:
                        id:
                          type: integer
                          description: Unique leave request ID
                          example: 123
                        user_id:
                          type: integer
                          description: ID of the user who created the request
                          example: 456
                        leave_type:
                          "$ref": "#/components/schemas/LeaveTypeBasic"
                        start_date:
                          type: string
                          format: date
                          description: Start date of leave
                          example: '2024-03-15'
                        end_date:
                          type: string
                          format: date
                          description: End date of leave
                          example: '2024-03-17'
                        hours_calculated:
                          type: number
                          description: Total hours for this leave request (primary
                            field)
                          example: 24
                        business_days:
                          type: number
                          description: 'DEPRECATED: Use hours_calculated instead.
                            Returns same value as hours_calculated for backward compatibility.'
                          example: 24
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - denied
                          - cancelled
                          - special_approval
                          description: Current status of the leave request
                          example: pending
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes for the leave request
                          example: Family vacation
                        created_at:
                          type: string
                          format: date-time
                          description: When the request was created
                          example: '2024-02-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When the request was last updated
                          example: '2024-02-15T10:00:00Z'
                      required:
                      - id
                      - user_id
                      - leave_type
                      - start_date
                      - end_date
                      - hours_calculated
                      - status
                      - created_at
                      - updated_at
                  confirmed:
                    type: boolean
                    example: false
                  confirmed_at:
                    type: string
                    format: date-time
                    nullable: true
                  total_hours:
                    type: number
                    format: float
                    example: 40.0
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/bulk_create":
    post:
      tags:
      - Availability
      summary: Bulk create user availabilities
      description: "Create multiple availability blocks in a single request. \nReturns
        success and failure results for each item.\n"
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - availabilities
              properties:
                availabilities:
                  type: array
                  items:
                    type: object
                    required:
                    - specific_date
                    - start_time
                    - end_time
                    properties:
                      specific_date:
                        type: string
                        format: date
                      start_time:
                        type: string
                        format: time
                      end_time:
                        type: string
                        format: time
                      notes:
                        type: string
      responses:
        '200':
          description: Bulk operation completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: object
                    properties:
                      success:
                        type: array
                        items:
                          type: object
                          description: User availability block information
                          properties:
                            id:
                              type: integer
                              description: Unique availability ID
                              example: 123
                            specific_date:
                              type: string
                              format: date
                              description: Date for this availability block
                              example: '2025-10-15'
                            start_time:
                              type: string
                              format: time
                              description: Start time (displayed in user's timezone)
                              example: '09:00'
                            end_time:
                              type: string
                              format: time
                              description: End time (displayed in user's timezone)
                              example: '17:00'
                            notes:
                              type: string
                              nullable: true
                              description: Optional notes about this availability
                              example: Available for morning shift
                            store_in_utc:
                              type: boolean
                              description: Whether times are stored in UTC
                              example: true
                            created_at:
                              type: string
                              format: date-time
                              description: When this availability was created
                              example: '2025-10-01T10:30:00Z'
                            updated_at:
                              type: string
                              format: date-time
                              description: When this availability was last updated
                              example: '2025-10-01T10:30:00Z'
                          required:
                          - id
                          - specific_date
                          - start_time
                          - end_time
                      failed:
                        type: array
                        items:
                          type: object
                          properties:
                            date:
                              type: string
                            error:
                              type: string
                  success_count:
                    type: integer
                  failed_count:
                    type: integer
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/mark_available_all_week":
    post:
      tags:
      - Availability
      summary: Mark available for entire week
      description: |
        Mark the user as available for an entire week by creating availability blocks
        for each day. Any existing availability for the week is cleared first, then new
        blocks are created based on the business's minimum block size setting.

        Days with approved leave are automatically skipped. Past dates within the week
        are also skipped. Weekends are excluded by default unless `include_weekends` is true.

        For the current day, if the requested start time has already passed, availability
        begins from the next hour.
      operationId: markAvailableAllWeekUserAvailabilities
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                week_start:
                  type: string
                  format: date
                  description: Start date of the week (YYYY-MM-DD). Defaults to the
                    current week.
                  example: '2026-03-02'
                start_time:
                  type: string
                  description: Daily start time in HH:MM format. Defaults to "00:00".
                  example: '08:00'
                end_time:
                  type: string
                  description: Daily end time in HH:MM format. Defaults to "23:59".
                  example: '17:00'
                include_weekends:
                  type: boolean
                  description: Whether to include Saturday and Sunday. Defaults to
                    false.
                  default: false
                  example: false
      responses:
        '200':
          description: Week availability created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Human-readable success message
                    example: Successfully marked available for the week of March 02,
                      2026.
                  week_start:
                    type: string
                    format: date
                    description: Start date of the week
                    example: '2026-03-02'
                  week_end:
                    type: string
                    format: date
                    description: End date of the week
                    example: '2026-03-08'
                  created_count:
                    type: integer
                    description: Number of new availability blocks created
                    example: 30
                  cleared_count:
                    type: integer
                    description: Number of existing blocks that were cleared
                    example: 0
                  skipped_leave_count:
                    type: integer
                    description: Number of days skipped due to approved leave
                    example: 0
                  availabilities:
                    type: array
                    description: All availability blocks for the week after the operation
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
        '400':
          description: Invalid date or time format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                invalid_date:
                  summary: Invalid date format
                  value:
                    error:
                      code: invalid_date
                      message: Invalid week_start date format
                invalid_time:
                  summary: Invalid time format
                  value:
                    error:
                      code: invalid_time
                      message: Invalid start_time or end_time format. Use HH:MM.
        '422':
          description: Week is confirmed or in the past
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                week_confirmed:
                  summary: Week is confirmed
                  value:
                    error:
                      code: week_confirmed
                      message: Cannot modify availability for confirmed weeks.
                past_week:
                  summary: Week is in the past
                  value:
                    error:
                      code: past_week
                      message: Cannot modify availability for past weeks.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/clear_week":
    delete:
      tags:
      - Availability
      summary: Clear all availability for a week
      description: |
        Delete all availability blocks for the specified week. The week must not be
        confirmed and must not be in the past. Use this to reset a user's availability
        for an entire week in a single operation.
      operationId: clearWeekUserAvailabilities
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: |
          Start date of the week to clear (YYYY-MM-DD). Defaults to the current
          business week if not provided.
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Week availability cleared successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Human-readable success message
                    example: Successfully cleared 5 availability blocks for the week
                      of October 13, 2025.
                  cleared_count:
                    type: integer
                    description: Number of availability blocks that were deleted
                    example: 5
                  week_start:
                    type: string
                    format: date
                    description: Start date of the cleared week
                    example: '2025-10-13'
                  week_end:
                    type: string
                    format: date
                    description: End date of the cleared week
                    example: '2025-10-19'
        '400':
          description: Invalid date format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: invalid_date
                  message: Invalid week_start date format
        '404':
          description: No availability blocks found for the week
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: no_availability
                  message: No availability blocks found for the week of October 13,
                    2025.
        '422':
          description: Week is confirmed or in the past
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                week_confirmed:
                  summary: Week is confirmed
                  value:
                    error:
                      code: week_confirmed
                      message: Cannot clear availability for confirmed weeks. Unconfirm
                        the week first.
                past_week:
                  summary: Week is in the past
                  value:
                    error:
                      code: past_week
                      message: Cannot clear availability for past weeks.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/copy_week_to_week":
    post:
      tags:
      - Availability
      summary: Copy availability from one week to another
      description: |
        Copy all availability blocks from a source week to a target week. Existing
        availability in the target week is preserved — duplicate blocks are skipped
        rather than overwritten.

        Days with approved leave in the target week are automatically skipped.
        The target week must not be confirmed.
      operationId: copyWeekToWeekUserAvailabilities
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - source_week_start
              - target_week_start
              properties:
                source_week_start:
                  type: string
                  format: date
                  description: Start date of the source week to copy from (YYYY-MM-DD).
                  example: '2026-03-02'
                target_week_start:
                  type: string
                  format: date
                  description: Start date of the target week to copy to (YYYY-MM-DD).
                  example: '2026-03-09'
      responses:
        '200':
          description: Availability copied successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    description: Whether the operation succeeded
                    example: true
                  message:
                    type: string
                    description: Human-readable success message
                    example: Successfully copied 5 availability blocks
                  copied_count:
                    type: integer
                    description: Number of availability blocks copied
                    example: 5
                  duplicate_count:
                    type: integer
                    description: Number of blocks skipped because they already existed
                      in the target week
                    example: 0
                  skipped_leave_count:
                    type: integer
                    description: Number of blocks skipped because of approved leave
                      in the target week
                    example: 1
                  source_week_start:
                    type: string
                    format: date
                    description: Start date of the source week
                    example: '2026-03-02'
                  target_week_start:
                    type: string
                    format: date
                    description: Start date of the target week
                    example: '2026-03-09'
                  availabilities:
                    type: array
                    description: All availability blocks for the target week after
                      the operation
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
        '400':
          description: Invalid date format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: invalid_date
                  message: Invalid source_week_start date format. Use YYYY-MM-DD.
        '422':
          description: Validation error (same week, confirmed, or past)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                same_week:
                  summary: Source and target are the same week
                  value:
                    error:
                      code: same_week
                      message: Source and target weeks must be different.
                week_confirmed:
                  summary: Target week is confirmed
                  value:
                    error:
                      code: week_confirmed
                      message: Cannot copy availability to a confirmed week. Unconfirm
                        the target week first.
                past_week:
                  summary: Target week is in the past
                  value:
                    error:
                      code: past_week
                      message: Cannot copy availability to a past week.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/{id}":
    get:
      tags:
      - Availability
      summary: Get specific availability
      description: Get details of a specific availability block
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Availability retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availability:
                    type: object
                    description: User availability block information
                    properties:
                      id:
                        type: integer
                        description: Unique availability ID
                        example: 123
                      specific_date:
                        type: string
                        format: date
                        description: Date for this availability block
                        example: '2025-10-15'
                      start_time:
                        type: string
                        format: time
                        description: Start time (displayed in user's timezone)
                        example: '09:00'
                      end_time:
                        type: string
                        format: time
                        description: End time (displayed in user's timezone)
                        example: '17:00'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about this availability
                        example: Available for morning shift
                      store_in_utc:
                        type: boolean
                        description: Whether times are stored in UTC
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: When this availability was created
                        example: '2025-10-01T10:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this availability was last updated
                        example: '2025-10-01T10:30:00Z'
                    required:
                    - id
                    - specific_date
                    - start_time
                    - end_time
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Availability
      summary: Update user availability
      description: 'Update an existing availability block. Date must still be editable.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                start_time:
                  type: string
                  format: time
                  example: '09:00'
                end_time:
                  type: string
                  format: time
                  example: '17:00'
                notes:
                  type: string
                  example: Updated availability
      responses:
        '200':
          description: Availability updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availability:
                    type: object
                    description: User availability block information
                    properties:
                      id:
                        type: integer
                        description: Unique availability ID
                        example: 123
                      specific_date:
                        type: string
                        format: date
                        description: Date for this availability block
                        example: '2025-10-15'
                      start_time:
                        type: string
                        format: time
                        description: Start time (displayed in user's timezone)
                        example: '09:00'
                      end_time:
                        type: string
                        format: time
                        description: End time (displayed in user's timezone)
                        example: '17:00'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about this availability
                        example: Available for morning shift
                      store_in_utc:
                        type: boolean
                        description: Whether times are stored in UTC
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: When this availability was created
                        example: '2025-10-01T10:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this availability was last updated
                        example: '2025-10-01T10:30:00Z'
                    required:
                    - id
                    - specific_date
                    - start_time
                    - end_time
                  message:
                    type: string
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error or date not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Availability
      summary: Delete user availability
      description: 'Delete an availability block. Date must still be editable.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Availability deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  id:
                    type: string
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Date not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_availability_confirmations/status":
    get:
      tags:
      - Availability
      summary: Get weekly confirmation status
      description: 'Check if a specific week''s availability has been confirmed by
        the user.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: Week start date (defaults to current week)
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Confirmation status retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  week_start:
                    type: string
                    format: date
                  week_end:
                    type: string
                    format: date
                  confirmed:
                    type: boolean
                  confirmed_at:
                    type: string
                    format: date-time
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  auto_generated:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_availability_confirmations":
    post:
      tags:
      - Availability
      summary: Confirm weekly availability
      description: |
        Confirm user's availability for a specific week. This locks the week from editing
        and signals to managers that the schedule is final.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - week_start
              properties:
                week_start:
                  type: string
                  format: date
                  example: '2025-10-13'
                notes:
                  type: string
                  example: Confirmed for this week
      responses:
        '201':
          description: Availability confirmed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  confirmation:
                    type: object
                    description: Weekly availability confirmation information
                    properties:
                      id:
                        type: integer
                        description: Unique confirmation ID
                        example: 456
                      start_date:
                        type: string
                        format: date
                        description: Week start date
                        example: '2025-10-13'
                      end_date:
                        type: string
                        format: date
                        description: Week end date
                        example: '2025-10-19'
                      confirmed_at:
                        type: string
                        format: date-time
                        description: When the availability was confirmed
                        example: '2025-10-12T15:30:00Z'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about the confirmation
                        example: Confirmed for this week
                      auto_generated:
                        type: boolean
                        description: Whether this was auto-generated by the system
                        example: false
                      created_at:
                        type: string
                        format: date-time
                        description: When this confirmation was created
                        example: '2025-10-12T15:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this confirmation was last updated
                        example: '2025-10-12T15:30:00Z'
                    required:
                    - id
                    - start_date
                    - end_date
                    - confirmed_at
                    - auto_generated
                  message:
                    type: string
        '422':
          description: Cannot confirm past week or validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Availability
      summary: Unconfirm weekly availability
      description: |
        Remove the confirmation for a specific week, allowing the user to edit their availability again.
        Cannot unconfirm past weeks.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - week_start
              properties:
                week_start:
                  type: string
                  format: date
                  example: '2025-10-13'
      responses:
        '200':
          description: Confirmation removed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  week_start:
                    type: string
        '422':
          description: Cannot unconfirm past week
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills":
    get:
      tags:
      - Skills & Certifications
      summary: List available skills
      description: |
        Get a list of skills available in the business. Supports filtering, searching, and sorting.

        **Use Cases:**
        - Browse skills for adding to employee profile
        - Search for specific skills
        - Filter by category or certification requirements
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        description: Search skills by name or description
        schema:
          type: string
          example: javascript
      - name: category
        in: query
        description: Filter by skill category
        schema:
          type: string
          example: technical_skills
      - name: requires_certification
        in: query
        description: Filter by certification requirement
        schema:
          type: boolean
          example: true
      - name: sort_by
        in: query
        description: Sort skills by field
        schema:
          type: string
          enum:
          - name
          - category
          - popularity
          example: name
      - name: sort_order
        in: query
        description: Sort direction. For `name`/`category` the default is ascending.
          For `popularity`, results default to most-popular-first; `sort_order=asc`
          lists least-popular first.
        schema:
          type: string
          enum:
          - asc
          - desc
          default: asc
          example: desc
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          example: 1
      - name: per_page
        in: query
        description: Number of items per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          example: 25
      - name: detailed
        in: query
        description: 'When `true`, each item additionally includes `employee_count`
          (number of active employees holding the skill), `average_proficiency`, `metadata`,
          and `help_desk_tier`.

          '
        schema:
          type: boolean
          example: true
      responses:
        '200':
          description: Skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      oneOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          description:
                            type: string
                            nullable: true
                            example: Frontend and backend JavaScript programming
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: 'Catalog credential type. Clients map `license`
                              -> LIC badge, `certification` -> CERT badge, `skill`
                              -> no badge.

                              '
                            example: certification
                          requires_document_upload:
                            type: boolean
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                      - allOf:
                        - "$ref": "#/components/schemas/Skill"
                        - type: object
                          properties:
                            metadata:
                              type: object
                              nullable: true
                              description: Additional skill metadata
                            help_desk_tier:
                              type: integer
                              nullable: true
                              example: 2
                            employee_count:
                              type: integer
                              example: 15
                            average_proficiency:
                              type: number
                              format: float
                              nullable: true
                              example: 3.4
                      description: "`SkillDetailed` (with `employee_count`) is returned
                        when `?detailed=true`; otherwise the base `Skill` schema.\n"
                  total_count:
                    type: integer
                    description: Total number of skills matching the query (across
                      all pages)
                    example: 81
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/{id}":
    get:
      tags:
      - Skills & Certifications
      summary: Get skill details
      description: Get detailed information about a specific skill
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Skill ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Skill details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  skill:
                    allOf:
                    - "$ref": "#/components/schemas/Skill"
                    - type: object
                      properties:
                        metadata:
                          type: object
                          nullable: true
                          description: Additional skill metadata
                        help_desk_tier:
                          type: integer
                          nullable: true
                          example: 2
                        employee_count:
                          type: integer
                          example: 15
                        average_proficiency:
                          type: number
                          format: float
                          nullable: true
                          example: 3.4
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/categories":
    get:
      tags:
      - Skills & Certifications
      summary: Get skill categories
      description: Get list of skill categories with counts
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                          example: technical_skills
                        label:
                          type: string
                          example: Technical Skills
                        skill_count:
                          type: integer
                          example: 25
  "/skills/trending":
    get:
      tags:
      - Skills & Certifications
      summary: Get trending skills
      description: Get skills that have been added frequently by employees recently
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Trending skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  trending_skills:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          description:
                            type: string
                            nullable: true
                            example: Frontend and backend JavaScript programming
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: 'Catalog credential type. Clients map `license`
                              -> LIC badge, `certification` -> CERT badge, `skill`
                              -> no badge.

                              '
                            example: certification
                          requires_document_upload:
                            type: boolean
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                      - type: object
                        properties:
                          recent_additions:
                            type: integer
                            example: 8
                          total_employees:
                            type: integer
                            example: 15
  "/skills/recommended":
    get:
      tags:
      - Skills & Certifications
      summary: Get recommended skills
      description: Get AI-recommended skills for the current user based on role, peers,
        and career progression
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        description: Maximum number of recommendations
        schema:
          type: integer
          minimum: 1
          maximum: 20
          example: 10
      responses:
        '200':
          description: Recommended skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  recommended_skills:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          description:
                            type: string
                            nullable: true
                            example: Frontend and backend JavaScript programming
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: 'Catalog credential type. Clients map `license`
                              -> LIC badge, `certification` -> CERT badge, `skill`
                              -> no badge.

                              '
                            example: certification
                          requires_document_upload:
                            type: boolean
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                      - type: object
                        properties:
                          recommendation_score:
                            type: number
                            format: float
                            example: 0.85
                          recommendation_reason:
                            type: string
                            example: 'Required for your current role: Software Engineer'
  "/skills/proficiency_levels":
    get:
      tags:
      - Skills & Certifications
      summary: Get proficiency levels
      description: 'Get the fixed 1–5 proficiency scale with this business''s configured
        level names and descriptions (falling back to platform defaults). Use this
        to populate a proficiency picker without hardcoding labels — the integer `level`
        is what you submit to the employee_skills endpoints.

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Proficiency levels retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  proficiency_levels:
                    type: array
                    items:
                      type: object
                      properties:
                        level:
                          type: integer
                          example: 3
                        name:
                          type: string
                          example: Intermediate
                        description:
                          type: string
                          example: Solid proficiency in the skill. Can work independently
                            on most tasks.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills":
    get:
      tags:
      - Skills & Certifications
      summary: List employee skills
      description: |
        Get the current user's skills with filtering and sorting options.

        **Use Cases:**
        - View employee's skill profile
        - Filter skills by category or status
        - Check certification expiration dates
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        description: Filter by skill category
        schema:
          type: string
          example: technical_skills
      - name: proficiency_level
        in: query
        description: Filter by proficiency level
        schema:
          type: integer
          minimum: 1
          maximum: 5
          example: 3
      - name: verification_status
        in: query
        description: Filter by verification status
        schema:
          type: string
          enum:
          - verified
          - unverified
          - pending
          example: verified
      - name: certification_status
        in: query
        description: Filter by certification status
        schema:
          type: string
          enum:
          - certified
          - expiring
          - expired
          example: certified
      - name: expiring_days
        in: query
        description: Days ahead to check for expiring certifications (used with certification_status=expiring)
        schema:
          type: integer
          minimum: 1
          example: 30
      - name: active
        in: query
        description: Filter by active status
        schema:
          type: boolean
          example: true
      - name: sort_by
        in: query
        description: Sort by field
        schema:
          type: string
          enum:
          - skill_name
          - proficiency_level
          - certification_date
          - expiration_date
          - created_at
          example: skill_name
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          example: 1
      - name: per_page
        in: query
        description: Number of items per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          example: 25
      responses:
        '200':
          description: Employee skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        skill_id:
                          type: integer
                          example: 123
                        proficiency_level:
                          type: integer
                          minimum: 1
                          maximum: 5
                          example: 3
                        certification_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2024-01-15'
                        expiration_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2025-01-15'
                        certification_number:
                          type: string
                          nullable: true
                          example: CERT-2024-001
                        notes:
                          type: string
                          nullable: true
                          example: Completed advanced course
                        verified:
                          type: boolean
                          example: true
                        verified_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-16T10:00:00Z'
                        active:
                          type: boolean
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T10:00:00Z'
                        skill:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 123
                            name:
                              type: string
                              example: JavaScript Development
                            category:
                              type: string
                              nullable: true
                              example: technical_skills
                            category_display:
                              type: string
                              nullable: true
                              example: Technical Skills
                            requires_certification:
                              type: boolean
                              example: true
                            skill_kind:
                              type: string
                              enum:
                              - skill
                              - certification
                              - license
                              description: Raw catalog credential type. Drives the
                                LIC/CERT badge in the web/mobile UI — clients map
                                license -> "LIC", certification -> "CERT", skill ->
                                no badge.
                              example: certification
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Skills & Certifications
      summary: Add employee skill
      description: |
        Add a new skill to the current user's profile.

        **Use Cases:**
        - Employee adds a new skill they possess
        - Record certification information
        - Set proficiency level
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - skill_id
              - proficiency_level
              properties:
                skill_id:
                  type: integer
                  description: ID of the skill to add
                  example: 123
                proficiency_level:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Proficiency level (1=Basic, 2=Beginner, 3=Intermediate,
                    4=Advanced, 5=Expert)
                  example: 3
                certification_date:
                  type: string
                  format: date
                  description: Date when certification was obtained
                  example: '2024-01-15'
                expiration_date:
                  type: string
                  format: date
                  description: Date when certification expires
                  example: '2025-01-15'
                certification_number:
                  type: string
                  description: Certification number or ID
                  example: CERT-2024-001
                notes:
                  type: string
                  description: Additional notes about the skill
                  example: Completed advanced JavaScript course
      responses:
        '201':
          description: Employee skill created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        proficiency_text:
                          type: string
                          example: Intermediate
                        verification_status:
                          type: string
                          enum:
                          - verified
                          - pending
                          - unverified
                          example: verified
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/employee_skills/{id}":
    get:
      tags:
      - Skills & Certifications
      summary: Get employee skill details
      description: Get detailed information about a specific employee skill
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Employee skill details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        proficiency_text:
                          type: string
                          example: Intermediate
                        verification_status:
                          type: string
                          enum:
                          - verified
                          - pending
                          - unverified
                          example: verified
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - Skills & Certifications
      summary: Update employee skill
      description: 'Update an existing employee skill. Note that updating verified
        skills will reset verification status unless force_update=true is used.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      - name: force_update
        in: query
        description: Force update even if skill is verified (resets verification)
        schema:
          type: boolean
          example: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                proficiency_level:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Proficiency level
                  example: 4
                certification_date:
                  type: string
                  format: date
                  description: Date when certification was obtained
                  example: '2024-01-15'
                expiration_date:
                  type: string
                  format: date
                  description: Date when certification expires
                  example: '2025-01-15'
                certification_number:
                  type: string
                  description: Certification number or ID
                  example: CERT-2024-002
                notes:
                  type: string
                  description: Additional notes about the skill
                  example: Updated certification information
      responses:
        '200':
          description: Employee skill updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        proficiency_text:
                          type: string
                          example: Intermediate
                        verification_status:
                          type: string
                          enum:
                          - verified
                          - pending
                          - unverified
                          example: verified
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Skills & Certifications
      summary: Remove employee skill
      description: Remove a skill from the current user's profile
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      responses:
        '204':
          description: Employee skill removed successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills/bulk":
    get:
      tags:
      - Skills & Certifications
      summary: Get skills for multiple users
      description: |
        Fetch confirmed skills for a set of users, grouped by user.

        Any authenticated caller may fetch the requested users' skills; access
        is not restricted by manager/role hierarchy. The only boundary is tenant
        isolation — requested user ids that are not members of the caller's
        business (or do not exist) are omitted from `users` and listed in
        `meta.skipped_user_ids` rather than failing the request. A maximum of
        100 user ids may be requested at once.
      security:
      - BearerAuth: []
      parameters:
      - name: user_ids
        in: query
        required: true
        description: User ids to fetch skills for (comma-separated or repeated). Max
          100.
        style: form
        explode: false
        schema:
          type: array
          items:
            type: integer
          example:
          - 12
          - 15
          - 999
      responses:
        '200':
          description: Skills retrieved successfully for the authorized users
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 12
                            name:
                              type: string
                              example: Jane Doe
                        skills:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 456
                              skill_id:
                                type: integer
                                example: 123
                              proficiency_level:
                                type: integer
                                minimum: 1
                                maximum: 5
                                example: 3
                              certification_date:
                                type: string
                                format: date
                                nullable: true
                                example: '2024-01-15'
                              expiration_date:
                                type: string
                                format: date
                                nullable: true
                                example: '2025-01-15'
                              certification_number:
                                type: string
                                nullable: true
                                example: CERT-2024-001
                              notes:
                                type: string
                                nullable: true
                                example: Completed advanced course
                              verified:
                                type: boolean
                                example: true
                              verified_at:
                                type: string
                                format: date-time
                                nullable: true
                                example: '2024-01-16T10:00:00Z'
                              active:
                                type: boolean
                                example: true
                              created_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                              updated_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                              skill:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                    example: 123
                                  name:
                                    type: string
                                    example: JavaScript Development
                                  category:
                                    type: string
                                    nullable: true
                                    example: technical_skills
                                  category_display:
                                    type: string
                                    nullable: true
                                    example: Technical Skills
                                  requires_certification:
                                    type: boolean
                                    example: true
                                  skill_kind:
                                    type: string
                                    enum:
                                    - skill
                                    - certification
                                    - license
                                    description: Raw catalog credential type. Drives
                                      the LIC/CERT badge in the web/mobile UI — clients
                                      map license -> "LIC", certification -> "CERT",
                                      skill -> no badge.
                                    example: certification
                  meta:
                    type: object
                    properties:
                      requested_count:
                        type: integer
                        example: 3
                      returned_count:
                        type: integer
                        example: 2
                      skill_row_count:
                        type: integer
                        description: Skill rows returned across all requested users.
                        example: 120
                      skill_row_limit:
                        type: integer
                        description: Maximum skill rows this endpoint returns in one
                          response.
                        example: 2500
                      truncated:
                        type: boolean
                        description: True when `skill_row_limit` was hit and later
                          rows were dropped. Which rows survive follows the request's
                          sort order.
                        example: false
                      skipped_user_ids:
                        type: array
                        items:
                          type: integer
                        example:
                        - 999
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills/expiring":
    get:
      tags:
      - Skills & Certifications
      summary: Get expiring skills
      description: Get employee skills with certifications that are expiring soon
      security:
      - BearerAuth: []
      parameters:
      - name: days
        in: query
        description: Number of days ahead to check for expiring certifications
        schema:
          type: integer
          minimum: 1
          example: 90
      responses:
        '200':
          description: Expiring skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  expiring_skills:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          proficiency_level:
                            type: integer
                            minimum: 1
                            maximum: 5
                            example: 3
                          certification_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          notes:
                            type: string
                            nullable: true
                            example: Completed advanced course
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                              requires_certification:
                                type: boolean
                                example: true
                              skill_kind:
                                type: string
                                enum:
                                - skill
                                - certification
                                - license
                                description: Raw catalog credential type. Drives the
                                  LIC/CERT badge in the web/mobile UI — clients map
                                  license -> "LIC", certification -> "CERT", skill
                                  -> no badge.
                                example: certification
                      - type: object
                        properties:
                          days_until_expiration:
                            type: integer
                            example: 15
                          expiration_urgency:
                            type: string
                            enum:
                            - expired
                            - critical
                            - warning
                            - notice
                            - normal
                            example: warning
                  total_count:
                    type: integer
                    example: 3
                  days_ahead:
                    type: integer
                    example: 90
                  truncated:
                    type: boolean
                    description: True when the result set hit `row_limit` and was
                      cut. When true, `total_count` counts the RETURNED rows, not
                      the whole matching set, and any summary/aggregate in this response
                      is likewise page-local.
                    example: false
                  row_limit:
                    type: integer
                    description: Maximum rows this endpoint will return in one response.
                    example: 500
  "/employee_skills/categories":
    get:
      tags:
      - Skills & Certifications
      summary: Get employee skills by category
      description: Get employee's skills grouped by category with statistics
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Skills categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        category:
                          type: string
                          example: technical_skills
                        category_display:
                          type: string
                          example: Technical Skills
                        skill_count:
                          type: integer
                          example: 5
                        average_proficiency:
                          type: number
                          format: float
                          example: 3.4
                        certified_count:
                          type: integer
                          example: 2
                        expiring_soon_count:
                          type: integer
                          example: 1
  "/employee_skills/{id}/request_verification":
    post:
      tags:
      - Skills & Certifications
      summary: Request skill verification
      description: Request verification for an employee skill from manager
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Verification request sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Verification request sent to your manager
                  employee_skill:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 456
                      skill_id:
                        type: integer
                        example: 123
                      proficiency_level:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 3
                      certification_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2024-01-15'
                      expiration_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2025-01-15'
                      certification_number:
                        type: string
                        nullable: true
                        example: CERT-2024-001
                      notes:
                        type: string
                        nullable: true
                        example: Completed advanced course
                      verified:
                        type: boolean
                        example: true
                      verified_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2024-01-16T10:00:00Z'
                      active:
                        type: boolean
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-15T10:00:00Z'
                      skill:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: Raw catalog credential type. Drives the LIC/CERT
                              badge in the web/mobile UI — clients map license ->
                              "LIC", certification -> "CERT", skill -> no badge.
                            example: certification
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/certifications":
    get:
      tags:
      - Skills & Certifications
      summary: List employee certifications
      description: |
        Get the current user's certifications with filtering and sorting options.

        **Use Cases:**
        - View all employee certifications
        - Check certification status and expiration dates
        - Filter by category or verification status
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        description: Filter by skill category
        schema:
          type: string
          example: technical_skills
      - name: status
        in: query
        description: Filter by certification status
        schema:
          type: string
          enum:
          - active
          - expired
          - expiring
          example: active
      - name: expiring_days
        in: query
        description: Days ahead to check for expiring certifications (used with status=expiring)
        schema:
          type: integer
          minimum: 1
          example: 30
      - name: verification_status
        in: query
        description: Filter by verification status
        schema:
          type: string
          enum:
          - verified
          - unverified
          example: verified
      - name: sort_by
        in: query
        description: Sort by field
        schema:
          type: string
          enum:
          - skill_name
          - certification_date
          - expiration_date
          - created_at
          example: expiration_date
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          example: 1
      - name: per_page
        in: query
        description: Number of items per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          example: 25
      responses:
        '200':
          description: Certifications retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        skill_id:
                          type: integer
                          example: 123
                        certification_date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        expiration_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2025-01-15'
                        certification_number:
                          type: string
                          nullable: true
                          example: CERT-2024-001
                        verified:
                          type: boolean
                          example: true
                        verified_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-16T10:00:00Z'
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T10:00:00Z'
                        skill:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 123
                            name:
                              type: string
                              example: JavaScript Development
                            category:
                              type: string
                              nullable: true
                              example: technical_skills
                            category_display:
                              type: string
                              nullable: true
                              example: Technical Skills
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/{id}":
    get:
      tags:
      - Skills & Certifications
      summary: Get certification details
      description: Get detailed information about a specific certification
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Certification ID (employee skill ID)
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Certification details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  certification:
                    allOf:
                    - "$ref": "#/components/schemas/Certification"
                    - type: object
                      properties:
                        notes:
                          type: string
                          nullable: true
                          example: Completed advanced course
                        proficiency_level:
                          type: integer
                          minimum: 1
                          maximum: 5
                          example: 3
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        status:
                          type: string
                          enum:
                          - active
                          - expiring_soon
                          - expiring_notice
                          - expired
                          example: active
                        document_attached:
                          type: boolean
                          example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/expiring":
    get:
      tags:
      - Skills & Certifications
      summary: Get expiring certifications
      description: Get certifications that are expiring soon with urgency levels
      security:
      - BearerAuth: []
      parameters:
      - name: days
        in: query
        description: Number of days ahead to check for expiring certifications
        schema:
          type: integer
          minimum: 1
          example: 90
      responses:
        '200':
          description: Expiring certifications retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  expiring_certifications:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          certification_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                      - type: object
                        properties:
                          days_until_expiration:
                            type: integer
                            example: 15
                          expiration_urgency:
                            type: string
                            enum:
                            - expired
                            - critical
                            - warning
                            - notice
                            - normal
                            example: warning
                          renewal_required:
                            type: boolean
                            example: true
                  total_count:
                    type: integer
                    example: 3
                  days_ahead:
                    type: integer
                    example: 90
                  truncated:
                    type: boolean
                    description: True when the result set hit `row_limit` and was
                      cut. When true, `total_count` counts the RETURNED rows, not
                      the whole matching set, and any summary/aggregate in this response
                      is likewise page-local.
                    example: false
                  row_limit:
                    type: integer
                    description: Maximum rows this endpoint will return in one response.
                    example: 500
                  summary:
                    type: object
                    properties:
                      critical:
                        type: integer
                        example: 1
                      warning:
                        type: integer
                        example: 2
                      notice:
                        type: integer
                        example: 0
  "/certifications/expired":
    get:
      tags:
      - Skills & Certifications
      summary: Get expired certifications
      description: Get certifications that have already expired
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Expired certifications retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  expired_certifications:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          certification_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                      - type: object
                        properties:
                          days_expired:
                            type: integer
                            example: 30
                          renewal_required:
                            type: boolean
                            example: true
                  total_count:
                    type: integer
                    example: 2
                  truncated:
                    type: boolean
                    description: True when the result set hit `row_limit` and was
                      cut. When true, `total_count` counts the RETURNED rows, not
                      the whole matching set.
                    example: false
                  row_limit:
                    type: integer
                    description: Maximum rows this endpoint will return in one response.
                    example: 500
  "/certifications/summary":
    get:
      tags:
      - Skills & Certifications
      summary: Get certification summary
      description: Get summary statistics of employee's certifications
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Certification summary retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  certification_summary:
                    type: object
                    properties:
                      total_certifications:
                        type: integer
                        example: 8
                      active_certifications:
                        type: integer
                        example: 6
                      expired_certifications:
                        type: integer
                        example: 2
                      expiring_soon:
                        type: integer
                        example: 1
                      verified_certifications:
                        type: integer
                        example: 7
                      by_category:
                        type: object
                        additionalProperties:
                          type: object
                          properties:
                            total:
                              type: integer
                            active:
                              type: integer
                            expiring_soon:
                              type: integer
                      recent_activity:
                        type: array
                        items:
                          type: object
                          properties:
                            skill_name:
                              type: string
                              example: JavaScript Development
                            certification_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            created_at:
                              type: string
                              format: date-time
                              example: '2024-01-15T10:00:00Z'
  "/certifications/{id}/renew":
    post:
      tags:
      - Skills & Certifications
      summary: Renew certification
      description: |
        Renew an existing certification with new dates and information.

        **Use Cases:**
        - Employee renews an expiring certification
        - Update certification with new expiration date
        - Record new certification number
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Certification ID (employee skill ID)
        schema:
          type: integer
          example: 456
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                new_certification_date:
                  type: string
                  format: date
                  description: New certification date
                  example: '2024-02-01'
                new_expiration_date:
                  type: string
                  format: date
                  description: New expiration date
                  example: '2025-02-01'
                new_certification_number:
                  type: string
                  description: New certification number
                  example: CERT-2024-003
                notes:
                  type: string
                  description: Notes about the renewal
                  example: Renewed with updated curriculum
      responses:
        '201':
          description: Certification renewed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Certification renewed successfully
                  certification:
                    allOf:
                    - "$ref": "#/components/schemas/Certification"
                    - type: object
                      properties:
                        notes:
                          type: string
                          nullable: true
                          example: Completed advanced course
                        proficiency_level:
                          type: integer
                          minimum: 1
                          maximum: 5
                          example: 3
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        status:
                          type: string
                          enum:
                          - active
                          - expiring_soon
                          - expiring_notice
                          - expired
                          example: active
                        document_attached:
                          type: boolean
                          example: true
                  renewal_id:
                    type: string
                    example: renewal-uuid-123
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/certifications/upload_document":
    post:
      tags:
      - Skills & Certifications
      summary: Upload certification document
      description: Upload a document file for a certification
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              - employee_skill_id
              properties:
                file:
                  type: string
                  format: binary
                  description: Certification document file
                employee_skill_id:
                  type: integer
                  description: Employee skill ID for the certification
                  example: 456
      responses:
        '200':
          description: Document uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Certification document uploaded successfully
                  document:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      filename:
                        type: string
                        example: certificate.pdf
                      content_type:
                        type: string
                        example: application/pdf
                      byte_size:
                        type: integer
                        example: 1024000
                      url:
                        type: string
                        example: "/rails/active_storage/blobs/xyz/certificate.pdf"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/safety_hub/dashboard":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Dashboard KPI tiles
      description: |
        Returns the five home-screen KPI tiles in display order. Each tile is
        self-describing — it carries its own label, count, and Bootstrap text
        color class so native-mobile clients render the same palette as the
        web Safety Hub dashboard.

        Tiles, in order:
          1. `days_without_injury` — days since the most recent injury-type
             incident (0 if none on record). Color: `text-success`.
          2. `certifications_expiring_soon` — count of certifications expiring
             within the business-configured reminder window (default 30 days),
             aggregated across EmployeeSkill, TrainingCertificate, and
             LmsTrainingRecord. Color: `text-warning`.
          3. `observations_this_month` — safety observations recorded since
             the start of the current month. Color: `text-primary`.
          4. `incidents_this_month` — non-cancelled incidents occurring since
             the start of the current month. Color: `text-danger`.
          5. `upcoming_toolbox_talks` — toolbox talks scheduled in the future.
             Color: `text-info`.
      responses:
        '200':
          description: Dashboard KPI tiles
          content:
            application/json:
              schema:
                type: object
                required:
                - tiles
                properties:
                  tiles:
                    type: array
                    description: KPI tiles in display order. Always 5 items.
                    minItems: 5
                    maxItems: 5
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - count
                      - color_class
                      properties:
                        key:
                          type: string
                          enum:
                          - days_without_injury
                          - certifications_expiring_soon
                          - observations_this_month
                          - incidents_this_month
                          - upcoming_toolbox_talks
                          description: Stable machine key identifying the tile.
                        label:
                          type: string
                          description: Human-readable title shown on the tile.
                          example: Days Without Injury
                        count:
                          type: integer
                          minimum: 0
                          description: Numeric value for the tile.
                          example: 42
                        color_class:
                          type: string
                          enum:
                          - text-success
                          - text-warning
                          - text-primary
                          - text-danger
                          - text-info
                          description: Bootstrap text color class to apply to the
                            tile value.
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: Forbidden — Safety Hub not enabled for the business
          or user lacks access:
  "/safety_hub/config":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Create-form picker vocabularies
      description: |
        The option lists a native client needs to render the Safety Hub create
        forms — the API twin of the selects the desktop `new` views build inline.

        Each block is present ONLY when its module is enabled for the business,
        mirroring every other endpoint here (a tenant with Permits switched off
        gets no `permit_types` / `contractors`). Keys are stable, so a client
        renders whatever blocks are present:

          * `incident_types`, `incident_severities` — the two required selects on
            the report-incident form. Sourced from the Incident model's own enums,
            so they can never drift from what `POST /safety_hub/incidents` accepts.
          * `observation_categories` — the required Category select on the
            observation form. This is the TENANT'S configured list (Safety Hub
            app setting), so it varies per business; values are display-ready
            strings (`label` == `value`).
          * `permit_types` — the Permit Type select, from the WorkPermit type
            catalog; each carries a human `label` and a Font Awesome `icon`.
          * `contractors` — the tenant's active vendor roster for the
            "Contractor (if external)" select; the permit form writes the chosen
            `id` to `vendor_id`.

        Read-only. Requires `read:safety_hub`. No pagination and no role branch —
        these are business-wide form vocabularies, identical for every member.
      responses:
        '200':
          description: The enabled modules' picker vocabularies.
          content:
            application/json:
              schema:
                type: object
                required:
                - config
                properties:
                  config:
                    type: object
                    description: |
                      Only the enabled modules' blocks are present. Absent keys
                      mean that module is off for the business.
                    properties:
                      incident_types:
                        type: array
                        description: Incident Type select options (Incidents module).
                        items:
                          type: object
                          required:
                          - value
                          - label
                          properties:
                            value:
                              type: string
                              example: near_miss
                            label:
                              type: string
                              example: Near Miss
                      incident_severities:
                        type: array
                        description: Severity select options (Incidents module).
                        items:
                          type: object
                          required:
                          - value
                          - label
                          properties:
                            value:
                              type: string
                              example: high
                            label:
                              type: string
                              example: High
                      observation_categories:
                        type: array
                        description: |
                          Observation Category select options (Observations
                          module). The tenant's configured list — label == value.
                        items:
                          type: object
                          required:
                          - value
                          - label
                          properties:
                            value:
                              type: string
                              example: PPE
                            label:
                              type: string
                              example: PPE
                      permit_types:
                        type: array
                        description: Permit Type select options (Permits module).
                        items:
                          type: object
                          required:
                          - value
                          - label
                          - icon
                          properties:
                            value:
                              type: string
                              example: hot_work
                            label:
                              type: string
                              example: Hot work
                            icon:
                              type: string
                              example: fa-fire
                      contractors:
                        type: array
                        description: |
                          Active vendors for the "Contractor (if external)"
                          select (Permits module). The permit form writes the
                          chosen `id` to `vendor_id`.
                        items:
                          type: object
                          required:
                          - id
                          - name
                          properties:
                            id:
                              type: integer
                              example: 42
                            name:
                              type: string
                              example: Acme Scaffolding Ltd
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: Forbidden — Safety Hub not enabled for the business
          or user lacks access:
  "/safety_hub/incidents":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List incidents
      description: |
        Personal feed by DEFAULT: incidents the caller reported or is assigned
        to investigate, newest first. Pass `team=true` for the business-wide
        feed; that requires Safety Hub manager access and is additionally
        site-scoped, so a manager restricted to particular locations sees only
        incidents at those sites.

        Anonymous incidents are included; filter on the client if needed.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` for the business-wide feed (manager-gated and
          site-scoped). Omit or set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: Filter by lifecycle status
        schema:
          type: string
          enum:
          - reported
          - investigating
          - investigation_completed
          - closed
          - cancelled
      - name: severity
        in: query
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: type
        in: query
        description: Filter by incident_type
        schema:
          type: string
          enum:
          - injury
          - near_miss
          - property_damage
          - environmental
          - security
          - vehicle_accident
          - equipment_failure
      - name: location_id
        in: query
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of incidents
          content:
            application/json:
              schema:
                type: object
                properties:
                  incidents:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Incident"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Report a new incident
      description: |
        Report a new incident — the native mirror of the mockup's "My
        Submission" → camera → "Continue without photo" → incident form flow,
        and the API twin of the desktop
        `Apps::SafetyHub::IncidentsController#create` / mobile `#create_incident`.

        **Any member may file a report** — the caller is always recorded as the
        reporter (and `created_by`) and the status starts `reported`. Only
        `title`, `description`, `incident_type`, `severity` and `occurred_at` are
        required; everything else is optional, so a phone can post the bare form
        the mockup shows and add photos, people and witnesses later.

        **Photos** are optional and attach through `MediaItem` (multipart
        `photos[]`), exactly as both web surfaces do, so the AI-vision /
        EXIF-strip / transcription pipelines fire and the detail screen's
        timeline picks them up. When the tenant has `require_photos_for_injuries`
        on, an **injury** report with no photo is refused (422).

        `location_id` / `alert_id` that don't belong to the caller's business are
        dropped (never 422'd). Manager notifications and investigator
        auto-assignment are the model's job and fire here identically to every
        other create path.

        Post-commit steps (photos, people, witnesses, WCB reportability) that
        fail **after** the incident row is written are collected into `warnings`
        rather than turning a filed report into an error — the report itself is
        still saved and returned.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        (the by-hand employee grant). Gated on the tenant's `incidents_enabled`
        module toggle.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SafetyHubIncidentCreateRequest"
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/SafetyHubIncidentCreateRequest"
              - type: object
                properties:
                  photos:
                    type: array
                    description: Optional scene photos/videos, attached as MediaItems.
                    items:
                      type: string
                      format: binary
      responses:
        '201':
          description: Incident reported
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
                  warnings:
                    type: array
                    description: |
                      Post-commit issues that did not block the report (a photo
                      that failed to attach, a person/witness row that failed
                      validation, WCB reportability that could not be
                      determined). Empty on a clean save.
                    items:
                      type: string
        '400':
          description: Bad request — the incident payload is missing entirely
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — the token lacks the write scope, or Safety Hub
            / the Incidents module is disabled
        '422':
          description: Unprocessable — validation failed, or an injury report is missing
            a required photo
  "/safety_hub/incidents/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one incident
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Incident detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Incident not found
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Edit an incident
      description: |
        Correct ONE incident from the native detail "⋯ → Edit incident" flow,
        which reopens the report form prefilled with the record's own title,
        description, immediate actions, type, severity and location. The API twin
        of the desktop `Apps::SafetyHub::IncidentsController#update` (mobile-web
        has no edit route, so the desktop controller is the authority).

        Authority mirrors the desktop `#authorize_incident_edit` and the detail
        endpoint's `permissions.can_edit`: the incident's **reporter**, OR a
        **safety-hub manager** whose accessible sites include the incident's site.
        A **closed / cancelled** record is frozen (audit-trail integrity) and the
        OSHA/WCB matrix narrows what a late-stage record accepts — both return 422.

        The permitted fields are the SAME set as create; **`status` is not
        editable here** (lifecycle moves through the dedicated workflow actions),
        and photos/people/witnesses are attached **additively** — a
        `require_photos_for_injuries` policy is not re-checked on edit, matching
        the web. A cross-tenant `location_id` / `alert_id` is dropped rather than
        rejected. Audit rows (`IncidentUpdate`) and platform/WCB side effects are
        the model's job and fire automatically.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        when editing your OWN report. Editing another user's incident via manager
        privilege requires the wide `write:safety_hub`. Gated on the tenant's
        `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - incident
              properties:
                incident:
                  type: object
                  description: |
                    Any subset of the editable fields. Fields omitted are left
                    unchanged.
                  properties:
                    title:
                      type: string
                      minLength: 3
                      maxLength: 255
                    description:
                      type: string
                      minLength: 10
                      maxLength: 5000
                    occurred_at:
                      type: string
                      format: date-time
                    location_id:
                      type: integer
                      description: A site in the caller's business; a cross-tenant
                        id is dropped.
                    incident_type:
                      type: string
                      enum:
                      - injury
                      - near_miss
                      - property_damage
                      - environmental
                      - security
                      - vehicle_accident
                      - equipment_failure
                    severity:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                    immediate_actions:
                      type: string
                    anonymous:
                      type: boolean
                    confidential:
                      type: boolean
                    days_away_from_work:
                      type: integer
                    days_restricted_work:
                      type: integer
                    fatality:
                      type: boolean
                    wcb_province:
                      type: string
                    alert_id:
                      type: integer
      responses:
        '200':
          description: Incident updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Non-fatal post-save issues (e.g. a photo that failed
                      to attach); the edit still succeeded.
        '400':
          description: Bad request — the incident payload is missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the reporter or a site manager, an own-scoped
            token editing another user's incident, or Safety Hub / the Incidents module
            disabled
        '404':
          description: Incident not found
        '422':
          description: Unprocessable — validation failed, or the incident is closed/cancelled
            or otherwise frozen by the OSHA/WCB status matrix
  "/safety_hub/incidents/{id}/investigators":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List an incident's candidate investigators
      description: |
        The candidate investigator list for ONE incident — the native mirror of
        the desktop show view's "Reassign Investigator" picker
        (`Apps::SafetyHub::IncidentsController#load_form_data`'s
        `@potential_investigators`), so a phone can render the same select before
        POSTing an assignment.

        The candidates are the SHARED `#potential_investigators` — active
        **super_admins / admins / managers** in the business, ordered by first
        name — the SAME relation the desktop picker and its server-side resolve
        use, so no surface can offer someone the others would not. One query for
        the whole list; each entry carries `is_current` (whether they are the
        incident's current assignee).

        **Manager-only, within accessible sites** — exactly the personas the web
        offers the control to (the Investigation Workflow panel is gated on
        `safety_hub_manager? && incident_within_accessible_sites?`, and the
        reassignment write it feeds carries `authorize_safety_hub_manager!` +
        `authorize_incident_site_access`). A plain member — even the reporter, who
        can read the incident itself — is refused. `reassignable` echoes the web's
        status gate (the picker renders only while the incident is `reported` or
        `investigating`); the list is still returned for a closed incident so a
        manager can see who would be eligible.

        Requires the **read** scope `read:safety_hub` (the manager roster is
        management data, so an own-scoped `read:own_safety_hub` token is refused).
        Gated on the tenant's `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The candidate investigator list
          content:
            application/json:
              schema:
                type: object
                properties:
                  investigators:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        is_current:
                          type: boolean
                          description: True for the incident's current assigned investigator.
                  current_investigator:
                    type: object
                    nullable: true
                    description: The currently assigned investigator, or null when
                      unassigned.
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  reassignable:
                    type: boolean
                    description: True while the incident is reported or investigating
                      (a closed/cancelled incident's investigator is frozen).
                  incident:
                    type: object
                    properties:
                      id:
                        type: integer
                      reference:
                        type: string
                        description: INC-<id>
                      status:
                        type: string
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the incident is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Incidents module disabled
        '404':
          description: Incident not found (or not in the caller's business)
  "/safety_hub/incidents/{id}/investigator":
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Assign or reassign an incident's investigator
      description: |
        Assign or reassign ONE incident's investigator — the native mirror of the
        desktop show view's manager-only "Reassign Investigator" picker
        (`Apps::SafetyHub::IncidentsController#assign_investigator`) and the write
        the candidate list (`GET .../investigators`) feeds.

        The target must be an active **super_admin / admin / manager** of the
        business — the SHARED `#potential_investigators` relation the picker, its
        desktop resolve and the candidate list all use — so this endpoint can never
        accept someone the select never offered. A missing `investigator_id` is a
        400; an id outside that eligible set is a 422.

        **Manager-only, within accessible sites** — exactly the personas the web
        offers the control to (`authorize_safety_hub_manager!` +
        `authorize_incident_site_access`). A closed / cancelled incident's
        investigator is frozen (the web renders the picker only while the incident
        is `reported` or `investigating`), returned here as a 422 state conflict.

        Routes through the shared `Incident#reassign_investigator!` — the SAME
        canonical door the desktop and mobile controllers use: it preserves the
        investigation start (a reassignment does not restart the clock), recomputes
        the deadline, seeds the investigation row only when none exists yet, and
        notifies the new investigator. The response is the full incident detail
        envelope, re-read so it reflects the fresh assignment.

        Requires the **write** scope `write:safety_hub` (assigning an investigator
        is a management action, so the wide scope is required; a narrow
        `write:own_safety_hub` token is refused). Gated on the tenant's
        `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - investigator_id
              properties:
                investigator_id:
                  type: integer
                  description: Id of the active safety admin/manager to assign as
                    investigator.
      responses:
        '200':
          description: Investigator assigned; the incident detail envelope is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '400':
          description: Bad request — investigator_id missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the incident is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Incidents module disabled
        '404':
          description: Incident not found (or not in the caller's business)
        '422':
          description: Unprocessable — the incident is closed/cancelled (its investigator
            is frozen), or investigator_id is not an active admin/manager of this
            business
    put:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Assign or reassign an incident's investigator (PUT alias)
      description: |
        PUT alias of the PATCH above — identical behaviour, for native clients that
        prefer PUT for an idempotent single-field assignment.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - investigator_id
              properties:
                investigator_id:
                  type: integer
                  description: Id of the active safety admin/manager to assign as
                    investigator.
      responses:
        '200':
          description: Investigator assigned; the incident detail envelope is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '400':
          description: Bad request — investigator_id missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the incident is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Incidents module disabled
        '404':
          description: Incident not found (or not in the caller's business)
        '422':
          description: Unprocessable — the incident is closed/cancelled (its investigator
            is frozen), or investigator_id is not an active admin/manager of this
            business
  "/safety_hub/incidents/{id}/complete_investigation":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Complete an incident's investigation
      description: |
        Record an incident investigation's conclusions and close it out — the
        mockup's incident-detail manager panel "Complete Investigation" form, and
        the native twin of the desktop
        `Apps::SafetyHub::IncidentsController#complete_investigation`.

        **Authority is MANAGER-ONLY within accessible sites**, unlike editing an
        incident (which the reporter may also do). The web offers this control to
        no persona but a **safety-hub manager** whose accessible sites include the
        incident — exactly the detail read's `permissions.can_manage_investigation`
        flag — so the API gates the same way: `safety_hub_manager?` AND the
        incident is within the caller's accessible sites. Because a completion is
        always a management action, the wide **`write:safety_hub`** scope is
        required unconditionally — an own-scoped `write:own_safety_hub` token is
        refused (there is no "own" narrow case here). Gated on the tenant's
        `incidents_enabled` module toggle.

        `findings` is **required** (an investigation record with no findings is not
        an investigation record, and the completion cannot be undone);
        `corrective_actions` is optional and round-trips to the investigation's
        `recommendations`. Both flow through `Incident#complete_investigation!` —
        the same door the web uses — which, in one transaction, advances the
        incident to `investigation_completed`, records the findings on the
        `IncidentInvestigation` row (seeding one and resolving an investigator when
        none is assigned), and writes the investigation-findings audit note onto
        the activity timeline.

        The completion is **one-way** — there is no reopen route and the edit door
        cannot walk `status` back — so an incident that is not `investigating`
        (still `reported`, or already `investigation_completed` / `closed` /
        `cancelled`) is refused with **422** rather than re-stamped over its own
        findings. The response is the full incident detail (with the investigation
        narrative, visible to the completing manager as a PII viewer) plus the
        viewer capability flags.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - findings
              properties:
                findings:
                  type: string
                  description: The investigation's conclusions. Required and non-blank.
                corrective_actions:
                  type: string
                  description: Corrective actions taken, recorded as the investigation's
                    `recommendations`. Optional.
      responses:
        '200':
          description: Investigation completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a site manager, the incident is outside the
            caller's sites, an own-scoped token, or Safety Hub / the Incidents module
            disabled
        '404':
          description: Incident not found
        '422':
          description: Unprocessable — findings are blank, the incident is not under
            investigation (already completed / not yet started), or no investigator
            could be resolved to record the findings against
  "/safety_hub/observations":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List safety observations
      description: |
        Personal feed by DEFAULT: observations the caller submitted. Pass
        `team=true` for the business-wide feed, which requires Safety Hub
        manager access.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` for the business-wide feed (manager-gated). Omit or
          set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: category
        in: query
        schema:
          type: string
        description: Free-form category (e.g., PPE, Housekeeping)
      - name: observation_type
        in: query
        schema:
          type: string
          enum:
          - positive
          - at_risk
          - near_miss
      - name: status
        in: query
        schema:
          type: string
          enum:
          - submitted
          - under_review
          - resolved
          - closed
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of observations
          content:
            application/json:
              schema:
                type: object
                properties:
                  observations:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyObservation"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Submit a safety observation
      description: |
        Submit a new safety observation — the native "My Submission" → camera →
        "Continue without photo" → incident form → tap **Observation** flow. The
        API twin of the web `Apps::SafetyHub::SafetyObservationsController#create`
        and mobile `#create_observation`.

        **Any member may file one**; the caller is recorded as the observer. Only
        `observation_type`, `category` and `description` are required — a phone
        can post the bare observation form and **add photos later**. `observed_at`
        defaults to now when omitted.

        Photos ride in as multipart `photos[]` and are stored as MediaItem (the
        AI-vision / EXIF-strip pipeline fires and the detail timeline renders
        them). A photo that fails AFTER the observation is saved is reported in
        the `warnings` array rather than failing the submission. An **at-risk** or
        **near-miss** observation notifies the site's managers and safety officers
        (a positive observation is a commendation, not an alert).

        `anonymous` is honoured **only** when the tenant enables
        `allow_anonymous_observations`; otherwise the observer is always recorded.
        A `location_id` or `safety_observation_campaign_id` the caller's tenant
        does not own is dropped (not a 422). `status` cannot be set here — a new
        observation always starts `submitted`.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        for a frontline employee token. Gated on the tenant's
        `observations_enabled` module toggle.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                safety_observation:
                  "$ref": "#/components/schemas/SafetyObservationCreate"
                anonymous:
                  type: boolean
                  description: |
                    Hide the observer identity. Honoured only when the tenant
                    enables anonymous observations.
                photos:
                  type: array
                  description: Photo/video uploads attached to the observation (optional).
                  items:
                    type: string
                    format: binary
          application/json:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                safety_observation:
                  "$ref": "#/components/schemas/SafetyObservationCreate"
                anonymous:
                  type: boolean
      responses:
        '201':
          description: Observation submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  warnings:
                    type: array
                    description: Per-photo attachment failures, if any (the observation
                      was still saved).
                    items:
                      type: string
        '400':
          description: Bad request — the safety_observation object is missing entirely
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — a read-only token, or Safety Hub / the Observations
            module disabled
        '422':
          description: Unprocessable — a required field is blank or invalid
          content:
            application/json:
              schema:
                type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: string
  "/safety_hub/observations/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one safety observation
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Observation detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Observation not found
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Edit a safety observation
      description: |
        Edit ONE observation — the mockup's observation-detail "⋯" menu → "Edit
        observation", and the native twin of the desktop
        `SafetyObservationsController#update`.

        **Authority** mirrors the desktop edit gate: the **observer** may correct
        their OWN observation, and a **site manager** may correct anyone's within
        their accessible sites. A member editing their own observation needs only
        the narrow `write:own_safety_hub` scope; editing another user's via manager
        privilege needs the wide `write:safety_hub` (an own-scoped token acting
        beyond itself is refused). Gated on the tenant's `observations_enabled`
        module toggle.

        **`status`** is accepted only from a manager — a member's value is dropped
        (workflow state is not theirs to mass-assign). **`anonymous`** is a
        ONE-WAY toggle: a reporter may anonymise an existing observation when the
        tenant enables `allow_anonymous_observations`, but never de-anonymise.
        **Photos** posted as multipart `photos[]` are ADDED through MediaItem
        (never replace the existing set); an upload that fails after the edit is
        committed is reported in `warnings` rather than failing the request.

        `PUT` is accepted as an alias of `PATCH`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                anonymous:
                  type: boolean
                  description: One-way — anonymise the observation (tenant policy
                    permitting).
                safety_observation:
                  type: object
                  properties:
                    observation_type:
                      type: string
                      enum:
                      - positive
                      - at_risk
                      - near_miss
                    category:
                      type: string
                    description:
                      type: string
                    action_taken:
                      type: string
                    follow_up_required:
                      type: boolean
                    follow_up_notes:
                      type: string
                    specific_location:
                      type: string
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is ignored).
                    safety_observation_campaign_id:
                      type: integer
                    observed_at:
                      type: string
                      format: date-time
                    status:
                      type: string
                      enum:
                      - submitted
                      - under_review
                      - resolved
                      - closed
                      description: Manager-only; ignored for a member.
          multipart/form-data:
            schema:
              type: object
              properties:
                anonymous:
                  type: boolean
                photos[]:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Photos/videos to ADD to the observation.
                safety_observation:
                  type: object
      responses:
        '200':
          description: Observation updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  permissions:
                    type: object
                    properties:
                      is_observer:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                      can_complete_follow_up:
                        type: boolean
                        description: True only when a follow-up is outstanding AND
                          the viewer is a site manager (mirrors the manager-only POST
                          .../complete_follow_up gate).
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Non-fatal issues (e.g. a photo that could not be
                      attached).
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the observer/manager, an own-scoped token reaching
            beyond itself, or Safety Hub / the Observations module disabled
        '404':
          description: Observation not found
        '422':
          description: Unprocessable — validation failed (e.g. a blank description)
    put:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Edit a safety observation (alias of PATCH)
      description: Alias of `PATCH /safety_hub/observations/{id}`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Observation updated
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Observation not found
        '422':
          description: Unprocessable
  "/safety_hub/observations/{id}/complete_follow_up":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Complete an observation's follow-up
      description: |
        Mark ONE observation's follow-up as complete — the mockup's observation
        detail "Complete Follow-up" button ("Mark this follow-up as complete? The
        observation will be marked resolved."), and the native twin of the desktop
        `SafetyObservationsController#mark_follow_up_complete`.

        **Authority is MANAGER-ONLY**, unlike editing an observation (which the
        observer may also do). The web offers this control to no persona but a
        **safety-hub manager** within their accessible sites, so the API gates the
        same way: `safety_hub_manager?` AND the observation is within the caller's
        accessible sites. Because a follow-up completion is always a management
        action, the wide **`write:safety_hub`** scope is required unconditionally —
        an own-scoped `write:own_safety_hub` token is refused (there is no "own"
        narrow case here). Gated on the tenant's `observations_enabled` module
        toggle.

        Routes through `SafetyObservation#mark_follow_up_complete!` — the same door
        the web uses — so it stamps `follow_up_completed_at` + `follow_up_completed_by`
        and advances `status` to `resolved` unless the observation is already
        `closed` (a closed observation records the completion but is NOT reopened).
        A follow-up that is not outstanding is refused with **422**: either the
        observation has no follow-up flagged, or it was already completed (the
        original completer/timestamp audit is never overwritten). Takes no request
        body.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Follow-up completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  permissions:
                    type: object
                    properties:
                      is_observer:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                      can_complete_follow_up:
                        type: boolean
                        description: True only when a follow-up is outstanding AND
                          the viewer is a site manager. After completion it is false
                          (the follow-up is no longer outstanding).
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the observation is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Observations module disabled
        '404':
          description: Observation not found
        '422':
          description: Unprocessable — no outstanding follow-up (none flagged, or
            already completed), or the observation is not in a completable state
  "/safety_hub/toolbox_talks":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List toolbox talks (personal or team)
      description: |
        Default (`team` absent or false) — **personal feed**: only talks where
        the current user is the facilitator OR appears in the talk's
        `expected_attendees` JSONB array. Sorted by `scheduled_at` ASC.

        Note: this is narrower than the desktop "My Toolbox Talks" view at
        `/apps/safety-hub/toolbox_talks/my`, which additionally surfaces past
        talks the user attended without being pre-listed (via
        `toolbox_talk_attendances`) and sorts newest first. The API
        deliberately scopes to facilitator + expected-attendee only.

        `team=true` — **team feed**: all toolbox talks in the business.
        Requires manager-level access (`manager_or_above?` OR safety-hub
        app-admin), matching the web "Toolbox Talks" admin surface. Sorted by
        `scheduled_at` DESC.

        Combinable with `status` and `scope` filters in either mode.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` to request the team feed (manager-gated). Omit or
          set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: Filter by lifecycle status
        schema:
          type: string
          enum:
          - scheduled
          - in_progress
          - completed
          - cancelled
      - name: scope
        in: query
        description: Filter by lifecycle scope
        schema:
          type: string
          enum:
          - upcoming
          - completed
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of toolbox talks
          content:
            application/json:
              schema:
                type: object
                properties:
                  toolbox_talks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ToolboxTalk"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled, no access, or team=true requested
            by a non-manager
  "/safety_hub/toolbox_talks/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one toolbox talk
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Toolbox talk detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  toolbox_talk:
                    "$ref": "#/components/schemas/ToolboxTalkDetail"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Toolbox talk not found
  "/safety_hub/topics":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List toolbox talk topics
      description: Active toolbox-talk topic templates for the current business.
      parameters:
      - name: category
        in: query
        schema:
          type: string
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of topics
          content:
            application/json:
              schema:
                type: object
                properties:
                  topics:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ToolboxTalkTopic"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
  "/safety_hub/certifications":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List certification requirements
      description: Returns certification requirements (templates) configured for the
        business.
      parameters:
      - name: category
        in: query
        schema:
          type: string
      - name: include_inactive
        in: query
        description: Set to "true" to include inactive requirements (otherwise only
          active are returned)
        schema:
          type: string
          enum:
          - 'true'
          - 'false'
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of certification requirements
          content:
            application/json:
              schema:
                type: object
                properties:
                  certifications:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyCertificationRequirement"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
  "/safety_hub/submissions":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Submissions feed (personal or team)
      description: |
        Returns a unified incidents + observations feed, sorted by
        `submitted_at` (record `created_at`) descending — most recent
        submissions first. Each row ships UI-ready metadata (icon, label,
        color tokens) so native-mobile clients don't have to re-derive the
        visual treatment.

        Default (`team` absent or false) — **personal feed**: only rows the
        current user reported/submitted, with anonymous rows excluded. Mirrors
        the desktop `/apps/safety-hub/submitted_by_me` action.

        `team=true` — **team feed**: all incidents and observations in the
        business, including anonymous rows. Requires manager-level access
        (`manager_or_above?` OR safety-hub app-admin), matching the web
        "Team > Incidents/Observations" surface.

        The response also includes a `summary` block with current-month tile
        counts whose scope follows the `team` parameter: the personal feed
        returns the current user's own submissions (anonymous excluded), and
        the team feed returns every submission in the business (anonymous
        included) — so the tiles always match the rows the user is looking
        at. Each tile carries a `label`, `count`, `color` (Bootstrap token),
        and `icon` (stable icon-name token) so clients can render headline
        metrics without computing them.

        Honors the per-module toggles (`incidents_enabled`,
        `observations_enabled`) from the Safety Hub marketplace-app
        configuration in both scopes; disabled modules contribute `0` to the
        summary and emit no rows.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      - name: team
        in: query
        description: |
          Set to `true` to request the team feed (manager-gated). Omit or
          set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: kind
        in: query
        description: |
          Optional filter that restricts the returned rows to a single
          submission kind. Omit to return both incidents and observations
          (default). Unknown values are ignored (both are returned). The
          `summary` tile counts always include BOTH incidents and
          observations (subject to the per-module toggles) regardless of
          this filter, so headline counts stay stable as clients toggle
          between kinds.
        required: false
        schema:
          type: string
          enum:
          - incident
          - observation
      responses:
        '200':
          description: Submission feed
          content:
            application/json:
              schema:
                type: object
                properties:
                  submissions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubSubmission"
                  summary:
                    "$ref": "#/components/schemas/SafetyHubSubmissionsSummary"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled, no access, or team=true requested
            by a non-manager
  "/safety_hub/corrective_actions":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Corrective actions register (personal or team)
      description: |
        The unified Corrective & Preventive Action register (Capa::Action) —
        the native mirror of the desktop
        `Apps::SafetyHub::CorrectiveActionsController#index`.

        Default (`team` absent or false) — **personal feed** ("My Corrective
        Actions"): the corrective/preventive actions ASSIGNED TO the caller.
        Mirrors the desktop `#visible_scope`'s non-manager branch. Leader Rounds
        issues are excluded (that register owns its own transitions).

        `team=true` — **team feed**: the whole register, requiring manager-level
        access (`manager_or_above?` OR safety-hub app-admin) and site-scoped
        identically to the desktop board (a site-restricted manager sees actions
        at their accessible sites, site-less actions, and anything assigned to
        them). An own-scoped token (`read:own_safety_hub` without
        `read:safety_hub`) is bounded to the personal feed.

        Gated on the tenant's `incidents_enabled` module toggle, exactly as the
        desktop register and its navigation item are.

        Rows are ordered open-work-first, then by due date ascending, then id
        descending — the register's shared ordering authority
        (`Capa::Action.open_first_order`) so this feed and the web open on the
        same first screen.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      - name: team
        in: query
        description: Set to `true` for the team feed (manager-gated). Omit/false for
          the personal feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: |
          A real status (`pending`, `in_progress`, `completed`, `cancelled`),
          or one of the pseudo-statuses `open` (pending+in_progress), `closed`
          (completed+cancelled), `overdue` (open actions past their due date),
          or `all` (no status filter). Omitted → no status filter. An
          unrecognized value returns 400. `overdue` is the native filter row's
          own chip (All · Pending · In Progress · Overdue · Completed ·
          Cancelled), so the whole single-select row can be driven through
          `status`; it maps to the same scope as the `overdue=true` flag.
        schema:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          - open
          - closed
          - overdue
          - all
      - name: overdue
        in: query
        description: |
          Set to `true` to return only open actions past their due date.
          Combinable with a real `status` (e.g. `status=in_progress&overdue=true`);
          on its own it is equivalent to `status=overdue`.
        schema:
          type: boolean
          default: false
      - name: priority
        in: query
        description: One of low/medium/high/critical. Unrecognized values are ignored
          (no filter applied).
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: action_type
        in: query
        description: One of the Capa action types. Unrecognized values are ignored.
        schema:
          type: string
          enum:
          - immediate
          - short_term
          - long_term
          - preventive
          - training
          - policy_change
          - equipment
          - environmental
      - name: assigned_to_id
        in: query
        description: |
          Team feed only (`team=true`) — narrow the board to a single
          assignee. Ignored in the personal feed (already scoped to the caller).
        schema:
          type: integer
      responses:
        '200':
          description: Corrective actions register slice
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_actions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubCorrectiveAction"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '400':
          description: Bad request — unrecognized status value
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub or the Incidents module disabled, no
            access, or team=true requested by a non-manager
  "/safety_hub/corrective_actions/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one corrective action
      description: |
        Full detail for ONE corrective/preventive action (Capa::Action) — the
        native "My Corrective Actions" detail screen: the priority/status/type
        chips, the Action Details card (source, assignee, due date, description),
        the Completion record, and the ISO 45001 effectiveness-verification
        record.

        Visible to exactly the personas the desktop
        `Apps::SafetyHub::CorrectiveActionsController#visible_scope` admits: the
        action's **assignee**, or a **safety-hub manager** whose accessible
        sites include it (incident-sourced rows inherit their incident's site;
        other rows are scoped by `location_id` with site-less rows kept; plus
        anything assigned directly to the caller). Leader Rounds-sourced actions
        are excluded (that ledger owns its own detail surface). An action the
        caller may not reach returns 404 — it never confirms the row exists.

        Beyond the list card, the payload adds `verified_by`,
        `effectiveness_notes` and `days_until_due`, and a viewer-relative
        `permissions` block so a native client renders the right controls
        (Assign / Mark Complete / Verify) without a second round trip.

        Gated on the tenant's `incidents_enabled` module toggle, exactly as the
        list and the desktop register are.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Corrective action detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub or the Incidents module disabled, or
            no access
        '404':
          description: Corrective action not found (or not visible to the caller)
  "/safety_hub/corrective_actions/{id}/complete":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Mark a corrective action complete
      description: |
        Close out ONE corrective/preventive action (Capa::Action) from the
        native "My Corrective Actions" detail — the mockup's "Update Action"
        panel, "Mark Complete" button and optional "Completion notes…" box.

        Authority mirrors the desktop
        `Apps::SafetyHub::CorrectiveActionsController#complete` and the detail
        endpoint's `permissions.can_complete`: a **safety-hub manager** OR the
        action's **assignee**, on an **open** (pending / in_progress) action.
        The action is resolved through the same desktop `#visible_scope` the
        detail read uses, so an action the caller may not reach is a 404.

        Routes through `Capa::Action#mark_completed!` — the same door the web
        uses — so an incident-sourced action also logs an `IncidentUpdate` audit
        row on its parent incident, and a cancelled / already-completed action is
        refused (422) rather than resurrected.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        when completing your OWN assigned action. Completing another user's action
        via manager privilege requires the wide `write:safety_hub`. Gated on the
        tenant's `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolution_notes:
                  type: string
                  maxLength: 4000
                  description: |
                    Optional completion notes ("Completion notes…" in the
                    mockup). A blank/omitted value preserves any resolution note
                    already on file; it is never erased.
      responses:
        '200':
          description: Corrective action completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the assignee or a manager, an own-scoped token
            acting on another user's action, or Safety Hub / the Incidents module
            disabled
        '404':
          description: Corrective action not found (or not visible to the caller)
        '422':
          description: Unprocessable — the action is already completed/cancelled,
            or the notes exceed the 4000-character limit
  "/safety_hub/corrective_actions/{id}/assignee":
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Reassign a corrective action
      description: |
        Reassign ONE open corrective/preventive action (Capa::Action) to a
        different business user — the mockup's "Assign to" picker on the "My
        Corrective Actions" detail.

        Authority mirrors the detail endpoint's `permissions.can_assign`:
        **manager-only**, on an **open** (pending / in_progress) action (a member,
        even the current assignee, cannot reassign — it is a management decision).
        A completed / cancelled action's assignee is its "closed out by"
        attribution, so its ownership is frozen and reassignment returns 422.

        Routes through `Capa::Action#assign!` (moves a pending action to
        in_progress and notifies the new assignee); the model's cross-tenant FK
        guard and this endpoint both reject an assignee who is not a member of the
        business.

        Requires the **write** scope: `write:safety_hub` (reassigning is a manager
        action beyond the caller's own data, so the wide scope is required; a
        narrow `write:own_safety_hub` token is refused). Gated on the tenant's
        `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - assigned_to_id
              properties:
                assigned_to_id:
                  type: integer
                  description: Id of the business user to assign the action to.
      responses:
        '200':
          description: Corrective action reassigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '400':
          description: Bad request — assigned_to_id missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, an own-scoped token, or Safety Hub
            / the Incidents module disabled
        '404':
          description: Corrective action not found (or not visible to the caller)
        '422':
          description: Unprocessable — the action is closed (its assignee is frozen),
            or assigned_to_id is not a member of this business
  "/safety_hub/permits":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List permits to work ("My Permits")
      description: |
        The caller's Permits to Work.

        Personal feed by DEFAULT ("My Permits"): permits the caller REQUESTED
        or AUTHORISED. Pass `team=true` for the whole board, which requires
        Safety Hub manager access and is site-scoped to the manager's
        accessible sites (plus site-less permits and anything they are
        personally on).

        Requires the Permits to Work module to be enabled for the business.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` for the whole board (manager-gated + site-scoped).
          Omit or set to `false` for the personal "My Permits" feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: |
          Filter by status. Accepts a real permit status
          (`draft`, `requested`, `approved`, `suspended`, `closed`,
          `cancelled`, `expired`) or one of the derived board views:
          `active` (issued and inside its work window), `awaiting`
          (requested / awaiting approval), `overrun` (issued and past its end
          time), or `all` (no status filter). Omitted → the OPEN board
          (draft / requested / approved / suspended), matching the web
          surfaces' default. An unrecognized or malformed value falls back to
          the OPEN board.
        schema:
          type: string
      - name: permit_type
        in: query
        description: |
          Filter by permit type — one of `hot_work`, `confined_space`,
          `working_at_height`, `electrical`, `excavation`, `lifting`,
          `general`.
        schema:
          type: string
          enum:
          - hot_work
          - confined_space
          - working_at_height
          - electrical
          - excavation
          - lifting
          - general
      - name: location_id
        in: query
        description: Narrow the board to a single site.
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Page size, clamped to [1, 100]. Defaults to 25.
        schema:
          type: integer
          default: 25
      responses:
        '200':
          description: Permit board slice
          content:
            application/json:
              schema:
                type: object
                properties:
                  permits:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubPermit"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub or the Permits to Work module disabled,
            no access, or team=true requested by a non-manager
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Raise a draft permit to work
      description: |
        Raise a DRAFT permit to work — the native mirror of the "My Permits"
        board's "Request Permit" (+) button and the API twin of the desktop
        `Apps::SafetyHub::PermitsController#create`. (Mobile-web has no
        permit-create route — permits are requested and authorised on the
        desktop board.)

        **Any member may raise one** — creation is gated on nothing but the
        Permits module toggle, and the web offers the button to every persona
        (only the page title differs: "My Permits" vs "Permits to Work"). The
        caller is always recorded as the **requester** and the permit is born a
        **draft** — issuing it (`request` → a DIFFERENT manager `approve`s) is a
        separate two-person control, never part of creation, so `status` is not
        accepted here.

        `permit_type`, `title`, `starts_at` and `ends_at` are required. A bad
        work window (end before start, or longer than 14 days), a cross-tenant
        `location_id`/`vendor_id`, or a missing field is refused with a 422 and
        the payload is left intact — never a silent save. `permit_number` is
        assigned automatically (`PTW-<year>-NNNN`, per business-year).

        **`precautions`** are the authoriser's confirmations — a non-manager
        caller's `precautions` key is dropped, so a requester can never pre-tick
        on their own draft the controls that gate issue.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        (the by-hand employee grant). Gated on the tenant's `permits_enabled`
        module toggle.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - work_permit
              properties:
                work_permit:
                  type: object
                  required:
                  - permit_type
                  - title
                  - starts_at
                  - ends_at
                  properties:
                    permit_type:
                      type: string
                      enum:
                      - hot_work
                      - confined_space
                      - working_at_height
                      - electrical
                      - excavation
                      - lifting
                      - general
                    title:
                      type: string
                      maxLength: 255
                    description:
                      type: string
                    area:
                      type: string
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is refused with a 422).
                    vendor_id:
                      type: integer
                      description: Contractor id in the caller's business (a foreign
                        id is refused with a 422).
                    starts_at:
                      type: string
                      format: date-time
                    ends_at:
                      type: string
                      format: date-time
                      description: Must be after starts_at and within 14 days of it.
                    isolations:
                      type: string
                    ppe:
                      type: string
                    gas_test:
                      type: string
                    emergency_arrangements:
                      type: string
                    hazards:
                      type: array
                      items:
                        type: string
                      description: The requester's identified hazards.
                    precautions:
                      type: object
                      additionalProperties:
                        type: boolean
                      description: Authoriser-only — a map of precaution text → confirmed.
                        Dropped for a non-manager caller.
      responses:
        '201':
          description: Draft permit created
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    type: object
                    properties:
                      is_requester:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                        description: True when the caller may save — the requester
                          of a fresh draft, or a manager while the permit is open.
                      can_confirm_precautions:
                        type: boolean
                        description: True only for a manager while the permit is open
                          — the authoriser-only precaution checklist is editable.
        '400':
          description: Bad request — the work_permit payload is missing entirely
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — the token lacks the write scope, or Safety Hub
            / the Permits to Work module is disabled
        '422':
          description: Unprocessable — validation failed (a missing field, an inverted/over-long
            window, or a cross-tenant site/contractor)
  "/safety_hub/permits/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one permit to work
      description: |
        Full permit detail — the validity window, requester (issuer),
        authoriser (approver), the type's hazards and the precaution checklist
        with each control's confirmed state, plus the closure record. Viewable
        by the requester, the authoriser, or a manager whose accessible sites
        include the permit's site (site-less permits stay manager-visible).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Permit detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    type: object
                    properties:
                      is_requester:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                        description: True when the caller may save — the requester
                          before issue, or a manager while the permit is open.
                      can_confirm_precautions:
                        type: boolean
                        description: True only for a manager while the permit is open
                          — the authoriser-only precaution checklist is editable.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — you may only view permits you requested or authorised
        '404':
          description: Permit not found
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Save a permit to work
      description: |
        Save ONE permit's working record — the mockup's permit-detail "Save"
        button, and the native twin of the desktop `PermitsController#update`.

        ONE door, two personas, exactly as the shared web save: the **requester**
        fills the hazard checklist and the control notes (isolations, PPE, gas
        test, emergency arrangements) until the permit is issued, and a **manager**
        (the authoriser) confirms the **precaution** checklist — the gate on
        issue — while the permit is open.

        **Authority** mirrors the desktop edit gate: the **requester** may save
        their OWN permit while it is still `draft`/`requested` (they lose the pen
        once it is issued — the type, window, site and contractor also freeze
        then), and a **site manager** may save any permit within their accessible
        sites while it is open. A member saving their own permit needs only the
        narrow `write:own_safety_hub` scope; saving another user's via manager
        privilege needs the wide `write:safety_hub` (an own-scoped token acting
        beyond itself is refused). Gated on the tenant's `permits_enabled` module
        toggle.

        **`precautions`** are the authoriser's — a non-manager's `precautions`
        key is dropped before the write, so a requester can never sign off their
        own controls. **Issuing** the permit (`draft`/`requested` → `approved`) is
        a SEPARATE control — this action only saves the working record and never
        advances the lifecycle. A cross-tenant `location_id`/`vendor_id` is
        refused with a 422 (the model's belongs-to-business validation).

        `PUT` is accepted as an alias of `PATCH`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - work_permit
              properties:
                work_permit:
                  type: object
                  properties:
                    title:
                      type: string
                    description:
                      type: string
                    area:
                      type: string
                    permit_type:
                      type: string
                      enum:
                      - hot_work
                      - confined_space
                      - working_at_height
                      - electrical
                      - excavation
                      - lifting
                      - general
                      description: Frozen once the permit is issued.
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is refused with a 422). Frozen once issued.
                    vendor_id:
                      type: integer
                      description: Contractor id in the caller's business. Frozen
                        once issued.
                    starts_at:
                      type: string
                      format: date-time
                      description: Frozen once issued.
                    ends_at:
                      type: string
                      format: date-time
                      description: Frozen once issued.
                    isolations:
                      type: string
                    ppe:
                      type: string
                    gas_test:
                      type: string
                    emergency_arrangements:
                      type: string
                    hazards:
                      type: array
                      items:
                        type: string
                      description: The requester's identified hazards (replaces the
                        set).
                    precautions:
                      type: object
                      additionalProperties:
                        type: boolean
                      description: Authoriser-only — a map of precaution text → confirmed.
                        Dropped for a non-manager caller. Only controls in the permit
                        type's catalog are recorded.
      responses:
        '200':
          description: Permit saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    type: object
                    properties:
                      is_requester:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                        description: True when the caller may save — the requester
                          before issue, or a manager while the permit is open.
                      can_confirm_precautions:
                        type: boolean
                        description: True only for a manager while the permit is open
                          — the authoriser-only precaution checklist is editable.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            an own-scoped token reaching beyond itself, or Safety Hub / the Permits
            module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — validation failed (e.g. a blank title, or a
            cross-tenant / frozen field)
    put:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Save a permit to work (alias of PATCH)
      description: Alias of `PATCH /safety_hub/permits/{id}`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Permit saved
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Permit not found
        '422':
          description: Unprocessable
  "/safety_hub/permits/{id}/request_approval":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Send a permit for approval
      description: |
        Send ONE draft permit for approval — the mockup's permit-detail
        "Request approval" button (shown for a `draft` permit to a caller who may
        edit it), and the native twin of the desktop
        `Apps::SafetyHub::PermitsController#request_approval`. Moves the permit
        `draft` → `requested` and notifies the authorisers, exactly as the web does.

        **Authority is the SAME door the save uses** — the desktop wires both
        "Request approval" and the working-record save behind one edit gate — so
        this action gates identically: the **requester** while the permit is still
        `draft`/`requested` (own data → the narrow `write:own_safety_hub` scope
        suffices), OR a **site manager** whose accessible sites include the permit
        while it is open (acting on another user's permit via manager privilege
        needs the wide `write:safety_hub` — an own-scoped token reaching beyond
        itself is refused). Gated on the tenant's `permits_enabled` module toggle.

        The lifecycle guard is the model's (`WorkPermit#request!`): only a **draft**
        can be requested. A `requested`/`approved`/`suspended`/`closed`/`cancelled`/
        `expired` permit is refused with **422** — the button's own draft-only
        visibility is enforced server-side and never trusted from the client. Takes
        no request body.

        The response re-reads the full permit detail (same eager-load set as the
        detail read — no N+1) with the refreshed viewer permissions, so the client
        repaints the detail screen — the new `requested` state and the now-absent
        Request-approval affordance — without a second round trip.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Approval requested (permit is now `requested`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    type: object
                    properties:
                      is_requester:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                        description: True when the caller may save — the requester
                          before issue, or a manager while the permit is open.
                      can_confirm_precautions:
                        type: boolean
                        description: True only for a manager while the permit is open
                          — the authoriser-only precaution checklist is editable.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            an own-scoped token reaching beyond itself, or Safety Hub / the Permits
            module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — the permit is not a draft (only a draft can
            be sent for approval)
  "/safety_hub/permits/{id}/cancel":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Cancel a permit
      description: |
        Cancel ONE permit to work — the mockup's permit-detail red "Cancel"
        control (shown for a non-terminal permit to a caller who may edit it), and
        the native twin of the desktop `Apps::SafetyHub::PermitsController#cancel`.
        Cancellation is **terminal**: it flips any still-open permit
        (`draft`/`requested`/`approved`/`suspended`) straight to `cancelled`,
        records the reason, and notifies the permit holders (requester +
        authoriser, except the actor), exactly as the web does. It is the "scrap
        this permit" door — because the model freezes a permit's core fields once
        it is issued, the sanctioned way to undo an issued permit is to cancel it
        and raise a fresh one.

        **Authority is the SAME door the save/request use** — the desktop wires
        cancel, the working-record save, and "Request approval" behind one edit
        gate — so this action gates identically: the **requester** while the permit
        is still `draft`/`requested` (own data → the narrow `write:own_safety_hub`
        scope suffices), OR a **site manager** whose accessible sites include the
        permit while it is open (acting on another user's permit via manager
        privilege needs the wide `write:safety_hub` — an own-scoped token reaching
        beyond itself is refused). A `closed`/`cancelled`/`expired` permit is
        cancellable by nobody — both arms of the gate reject it (**403**). Gated on
        the tenant's `permits_enabled` module toggle.

        The response re-reads the full permit detail (same eager-load set as the
        detail read — no N+1) with the refreshed viewer permissions, so the client
        repaints the detail screen — the new `cancelled` state and `cancelled_reason`
        — without a second round trip.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional free-text reason recorded on `cancelled_reason`.
                    Defaults to "Cancelled by {name}" when blank.
      responses:
        '200':
          description: Permit cancelled (permit is now `cancelled`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    type: object
                    properties:
                      is_requester:
                        type: boolean
                      is_manager:
                        type: boolean
                      can_edit:
                        type: boolean
                        description: False once cancelled — a terminal permit is editable
                          by nobody.
                      can_confirm_precautions:
                        type: boolean
                        description: False once cancelled — the permit is no longer
                          open.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            the permit is already closed/cancelled/expired, an own-scoped token reaching
            beyond itself, or Safety Hub / the Permits module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — the permit could not be cancelled (e.g. a concurrent
            close/expire made it terminal first)
  "/safety_hub/knowledge_base":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Safety Knowledge Base (list, filter & search)
      description: |
        The Safety Knowledge Base — the native mirror of the desktop
        `Apps::SafetyHub::KnowledgeBaseController` and the mockup's "Knowledge
        Base" tab. Employee self-service content, so it is available to EVERY
        persona (no `team=` param).

        There is no personal/team split. Instead, exactly as the desktop
        `#base_kb_scope` does, a **manager** (manager-or-above OR the safety-hub
        app-admin) sees every status — including `draft`, `failed` and
        `archived` editorial content — while a **member** sees only `active`
        (published) articles. Global system safety content is included for
        both.

        Free-text search (`q`) is a token-AND ILIKE across title / question /
        answer / content (word order does not matter). Results are ordered by
        the model's display order, then most-recent, with an id tiebreak so a
        tie group cannot drop or duplicate a row across a page boundary.

        `categories` echoes the seven category chips, each with an un-paginated
        count (respecting the `source_type` filter but NOT the `category`
        filter, so a chip's badge and the page it opens describe the same set),
        plus an `all` total.

        Requires Safety Hub to be enabled for the business.
      parameters:
      - name: category
        in: query
        description: |
          Filter to one Safety Hub category. An unknown value returns 400
          (it is never silently matched). A malformed container shape is
          ignored (treated as no filter).
        schema:
          type: string
          enum:
          - emergency_procedures
          - hazard_recognition
          - ppe_guides
          - safe_work_procedures
          - incident_response
          - toolbox_talk_content
          - general_safety
      - name: source_type
        in: query
        description: |
          Filter by content type. An unknown value is ignored (treated as no
          filter).
        schema:
          type: string
          enum:
          - faq
          - document
          - url
          - video
      - name: q
        in: query
        description: Free-text search across title / question / answer / content (token-AND
          ILIKE).
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Page size, clamped to [1, 100]. Defaults to 25.
        schema:
          type: integer
          default: 25
      responses:
        '200':
          description: A page of knowledge base articles
          content:
            application/json:
              schema:
                type: object
                properties:
                  knowledge_base:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubKnowledgeBaseEntry"
                  categories:
                    type: array
                    description: The category chips (an "all" total plus each of the
                      seven categories) with un-paginated counts.
                    items:
                      "$ref": "#/components/schemas/SafetyHubKbCategoryFacet"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  filters:
                    type: object
                    description: Echo of the filters actually applied.
                    properties:
                      category:
                        type: string
                        nullable: true
                      source_type:
                        type: string
                        nullable: true
                      q:
                        type: string
                        nullable: true
        '400':
          description: Bad request — unknown category
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled or no access
  "/safety_hub/knowledge_base/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one knowledge base article
      description: |
        Full article detail — title, category, status, the primary
        question/answer, any additional Q&A pairs (`faq_items`), the extracted
        body (`content`) for document/url/video entries, a safe external
        `source_url`, attached-file metadata, and the author + timestamps.

        A member requesting a non-active article (draft / failed / archived)
        receives 404 — the same content boundary the list enforces; a manager
        may open any status.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Knowledge base article detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  knowledge_base_entry:
                    "$ref": "#/components/schemas/SafetyHubKnowledgeBaseEntryDetail"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled or no access
        '404':
          description: Article not found
  "/onboarding/my_plan":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Get the caller's onboarding plan (phases + items)
      description: |
        Returns the caller's active `RecruitingOnboardingPlan` (status in
        `active`, `preboarding`, or `draft`) bucketed into the six fixed
        phases. Each phase carries its own `items` array, per-phase
        `progress` percent, and `items_total`/`items_completed` counts.

        Item types covered: `task`, `form`, `document`, `checkpoint`,
        `survey`, `training`. The `id` field is a stable composite
        (`<type>_<record_id>`) — clients should treat it opaquely.

        Phase boundaries (relative to `plan.start_date`):
          * `preboarding` — day_offset < 0
          * `day_1`       — day_offset 0..1
          * `week_1`      — day_offset 2..6
          * `month_1`     — day_offset 7..29
          * `month_2_3`   — day_offset 30..89
          * `beyond`      — day_offset >= 90

        Items are sorted within each phase by `(day_offset ASC, name ASC)`.

        `days_overdue` is populated only when the item's `due_date` is in
        the past AND its status is NOT one of `completed`, `verified`, or
        `skipped` — matching the red "X days overdue" badge in the desktop
        view. Otherwise `null`.

        `overall_progress` mirrors `RecruitingOnboardingPlan#completion_percentage`
        (the same value the desktop dashboard shows). It applies partial
        credit (0.75) for documents in the `uploaded` state, so it can
        diverge slightly from a naïve completed/total ratio over all phases.
      responses:
        '200':
          description: The caller's onboarding plan with phases and items.
          content:
            application/json:
              schema:
                type: object
                required:
                - plan
                - phases
                properties:
                  plan:
                    type: object
                    required:
                    - id
                    - status
                    - overall_progress
                    properties:
                      id:
                        type: integer
                        example: 42
                      status:
                        type: string
                        enum:
                        - active
                        - preboarding
                        - draft
                        example: preboarding
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-25'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-07-24'
                      days_remaining:
                        type: integer
                        nullable: true
                        example: 39
                      overall_progress:
                        type: integer
                        description: |
                          Plan-wide completion percent (0-100). Mirrors
                          RecruitingOnboardingPlan#completion_percentage
                          including partial credit for uploaded-but-unverified
                          documents.
                        example: 29
                  phases:
                    type: array
                    description: |
                      Always six entries, in fixed order. Empty phases
                      are returned with `items: []` and `progress: 0` so
                      the client can render every row unconditionally.
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - progress
                      - items_total
                      - items_completed
                      - items
                      properties:
                        key:
                          type: string
                          enum:
                          - preboarding
                          - day_1
                          - week_1
                          - month_1
                          - month_2_3
                          - beyond
                          example: preboarding
                        label:
                          type: string
                          example: Pre-boarding
                        description:
                          type: string
                          example: Before the start date
                        progress:
                          type: integer
                          description: Per-phase completion percent (0-100).
                          example: 60
                        items_total:
                          type: integer
                          example: 5
                        items_completed:
                          type: integer
                          example: 3
                        items:
                          type: array
                          items:
                            type: object
                            required:
                            - id
                            - type
                            - title
                            - status
                            properties:
                              id:
                                type: string
                                description: Stable composite (`<type>_<record_id>`).
                                example: document_19
                              type:
                                type: string
                                enum:
                                - task
                                - form
                                - document
                                - checkpoint
                                - survey
                                - training
                                example: document
                              title:
                                type: string
                                example: Upload BOSIET / OPITO Survival Certificate
                              due_date:
                                type: string
                                format: date
                                nullable: true
                                example: '2026-05-01'
                              status:
                                type: string
                                description: |
                                  Raw status from the underlying record (with the
                                  task model's `display_status` collapse applied
                                  for tasks). The full set:
                                  `pending`, `in_progress`, `submitted`, `scheduled`,
                                  `completed`, `verified`, `uploaded`, `rejected`,
                                  `overdue`, `skipped`.
                                example: pending
                              status_label:
                                type: string
                                description: Humanized form of `status` (e.g. "In
                                  progress").
                                example: Pending
                              status_color:
                                type: string
                                description: |
                                  Bootstrap contextual color token for
                                  `status`, identical to the desktop timeline
                                  badge. Prefix with `bg-`/`text-` to render.
                                enum:
                                - success
                                - info
                                - warning
                                - danger
                                - secondary
                                example: warning
                              icon:
                                type: string
                                description: FontAwesome icon class for the item type.
                                example: fas fa-tasks
                              icon_color:
                                type: string
                                description: |
                                  Bootstrap contextual color token for the
                                  item-type `icon`.
                                example: success
                              required:
                                type: boolean
                                example: true
                              assignee:
                                type: string
                                nullable: true
                                description: Display name of the assignee/owner (tasks
                                  + checkpoints only).
                                example:
                              days_overdue:
                                type: integer
                                nullable: true
                                description: |
                                  Days past due. `null` for items that are not
                                  overdue OR are in a terminal state
                                  (`completed` / `verified` / `skipped`).
                                example: 43
                              web_url:
                                type: string
                                format: uri
                                description: |
                                  Absolute URL of the item's detail page in the
                                  desktop Onboarding Hub — the same link the
                                  desktop timeline renders. Surveys and
                                  trainings have no standalone detail page, so
                                  they point at the plan page.
                                example: https://officechat-dev.workforce.mangoapps.com/apps/onboarding_hub/documents/19
                              mobile_url:
                                type: string
                                format: uri
                                description: |
                                  Absolute URL of the item's detail page on the
                                  mobile-web surface (`/m/apps/onboarding-hub/...`).
                                  Only task & document ship a dedicated mobile
                                  detail page; every other type falls back to
                                  the mobile plan page so the link always
                                  resolves.
                                example: https://officechat-dev.workforce.mangoapps.com/m/apps/onboarding-hub/documents/19
        '401':
          description: Authentication required.
        '404':
          description: The caller has no active onboarding plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: no_active_plan
                      message:
                        type: string
                        example: You do not have an active onboarding plan.
  "/onboarding/team":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Team Onboarding summary (overlapping bucket counts)
      description: |
        Aggregates onboarding items across the caller's active TEAM plans
        — the plans where the caller is the `hiring_manager` and status
        is one of `active`, `preboarding`, `draft`. Returns seven
        **overlapping** counts the mobile widget renders:

          - Six PHASE buckets (`preboarding`, `day_1`, `week_1`,
            `month_1`, `month_2_3`, `beyond`) — each item is counted in
            exactly one phase bucket. The six phase counts sum to
            `total_items`.
          - One OVERDUE bucket — cross-phase count of every item past
            its due date (`TimelinePhasesService.item_overdue?`).
            **Overdue items are ALSO counted in their phase bucket**,
            so the seven buckets do NOT sum to `total_items` — overdue
            is an overlapping subset, not a separate slice.

        Cross-endpoint invariants:

          - `/team` `buckets[phase_X].count` ≡ `/team/items?phase=X`
            `meta.total_count`
          - `/team` `buckets[overdue].count` ≡ `/team/items?status=overdue`
            `meta.total_count`

        Always returns HTTP 200, even when the caller has zero team plans
        — all `buckets[].count` are 0 and `team.plan_count` is 0. The
        widget hides itself client-side when `team.plan_count == 0`.
      responses:
        '200':
          description: Aggregated team onboarding counts.
          content:
            application/json:
              schema:
                type: object
                required:
                - team
                - buckets
                - total_items
                properties:
                  team:
                    type: object
                    required:
                    - plan_count
                    - member_count
                    properties:
                      plan_count:
                        type: integer
                        description: Number of active plans the caller manages.
                        example: 4
                      member_count:
                        type: integer
                        description: |
                          Distinct new-hires across those plans. Differs
                          from `plan_count` only in the rare case where a
                          single person has more than one active plan.
                        example: 4
                  buckets:
                    type: array
                    description: |
                      Seven entries in fixed order: `overdue` first, then
                      the six phases chronologically. The overdue bucket
                      OVERLAPS with the phase buckets (an overdue day_1
                      item counts in BOTH `overdue` AND `day_1`). Empty
                      buckets are still present with `count: 0` so the
                      client can render every legend row unconditionally.
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - count
                      properties:
                        key:
                          type: string
                          enum:
                          - overdue
                          - preboarding
                          - day_1
                          - week_1
                          - month_1
                          - month_2_3
                          - beyond
                          example: overdue
                        label:
                          type: string
                          example: Overdue
                        count:
                          type: integer
                          example: 14
                  total_items:
                    type: integer
                    description: |
                      Distinct count of items across the team. Equal to
                      the sum of the six PHASE bucket counts (each item is
                      in exactly one phase). The `overdue` bucket is a
                      subset and does NOT contribute to this sum.
                    example: 62
        '401':
          description: Authentication required.
  "/onboarding/team/items":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Team Onboarding drill-in (paginated items, phase + status filters)
      description: |
        Returns the line items in a manager's team plans, filtered by
        two independent axes — `phase` and `status` — plus optional
        free-text search. Powers the mobile widget's drill-in modal
        when a manager taps a pie slice or changes the dropdowns inside
        the modal.

        Filters compose. `phase=day_1&status=overdue` returns day_1
        items that are also past due. `phase=all&status=overdue` (the
        common "tap Overdue slice" path) returns every overdue item
        across all phases, matching the screenshot's "Phase: Any,
        Status: Overdue" view.

        Status partition (canonical 3-way collapse via
        `TimelinePhasesService.item_status_group`):

          * `overdue`   — item past `due_date` and not in a terminal
                          state (same predicate as the pie chart's
                          overdue bucket)
          * `completed` — item status ∈ {completed, verified}
          * `pending`   — everything else (pending, in_progress,
                          uploaded, submitted, scheduled, rejected,
                          skipped)

        Each row carries the originating `phase` AND the row's
        `status_group` regardless of how the filter was applied, so the
        client can render the Phase / Status columns the screenshot
        shows even when filtering on one axis.

        The `responsible` block is the new hire being onboarded
        (`plan.user` or `plan.recruiting_candidate`) — NOT the task's
        `assigned_to`. The latter, when set, is surfaced separately as
        `assignee` for tasks/checkpoints.
      parameters:
      - name: phase
        in: query
        description: |
          Phase filter. One of the six standard phase keys, or `all`
          to skip phase filtering.
        schema:
          type: string
          enum:
          - preboarding
          - day_1
          - week_1
          - month_1
          - month_2_3
          - beyond
          - all
          default: all
      - name: status
        in: query
        description: |
          Status filter — the canonical 3-way collapse. `all` skips
          status filtering.
        schema:
          type: string
          enum:
          - overdue
          - completed
          - pending
          - all
          default: all
      - name: search
        in: query
        description: Case-insensitive substring filter against item title and responsible
          name.
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated items matching the supplied filters.
          content:
            application/json:
              schema:
                type: object
                required:
                - filters
                - items
                - meta
                properties:
                  filters:
                    type: object
                    description: Echo of the applied filters so the client can re-render
                      the dropdowns.
                    properties:
                      phase:
                        type: object
                        properties:
                          key:
                            type: string
                            enum:
                            - preboarding
                            - day_1
                            - week_1
                            - month_1
                            - month_2_3
                            - beyond
                            - all
                            example: all
                          label:
                            type: string
                            example: All phases
                      status:
                        type: object
                        properties:
                          key:
                            type: string
                            enum:
                            - overdue
                            - completed
                            - pending
                            - all
                            example: overdue
                          label:
                            type: string
                            example: Overdue
                  items:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - type
                      - title
                      - status
                      - phase
                      - due_date
                      - responsible
                      - plan_id
                      - web_url
                      - mobile_url
                      properties:
                        id:
                          type: string
                          description: Stable composite (`<type>_<record_id>`).
                          example: training_42
                        type:
                          type: string
                          enum:
                          - task
                          - form
                          - document
                          - checkpoint
                          - survey
                          - training
                          example: training
                        title:
                          type: string
                          example: Attend Aberdeen Mobilisation Briefing
                        status:
                          type: string
                          description: |
                            Raw status (with the task model's
                            `display_status` collapse applied for tasks).
                          example: pending
                        status_label:
                          type: string
                          example: Pending
                        status_group:
                          type: string
                          description: |
                            Canonical 3-way classification of the item's
                            current state (overdue/completed/pending) —
                            same value the `status` filter accepts.
                          enum:
                          - overdue
                          - completed
                          - pending
                          example: overdue
                        status_color:
                          type: string
                          description: |
                            Bootstrap contextual color token for `status`,
                            identical to the desktop timeline badge. Prefix
                            with `bg-`/`text-` to render.
                          enum:
                          - success
                          - info
                          - warning
                          - danger
                          - secondary
                          example: warning
                        icon:
                          type: string
                          description: FontAwesome icon class for the item type.
                          example: fas fa-tasks
                        icon_color:
                          type: string
                          description: |
                            Bootstrap contextual color token for the
                            item-type `icon`.
                          example: success
                        required:
                          type: boolean
                          example: true
                        phase:
                          type: object
                          description: |
                            Original phase the item belongs to — distinct
                            from the bucket. An item in the `overdue`
                            bucket still reports its source phase here.
                          properties:
                            key:
                              type: string
                              enum:
                              - preboarding
                              - day_1
                              - week_1
                              - month_1
                              - month_2_3
                              - beyond
                              example: day_1
                            label:
                              type: string
                              example: Day 1
                        due_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-04-15'
                        days_overdue:
                          type: integer
                          nullable: true
                          example: 59
                        assignee:
                          type: string
                          nullable: true
                          description: Task/checkpoint assignee display name; nil
                            for forms, documents, surveys, trainings.
                          example:
                        responsible:
                          type: object
                          nullable: true
                          description: |
                            The new hire being onboarded
                            (`plan.user || plan.recruiting_candidate`).
                            Nil only when both are absent on a draft plan.
                          properties:
                            user_id:
                              type: integer
                              nullable: true
                              example: 42
                            candidate_id:
                              type: integer
                              nullable: true
                              example:
                            name:
                              type: string
                              example: Aldin Cumpton
                            initials:
                              type: string
                              example: AC
                            avatar_url:
                              type: string
                              format: uri
                              nullable: true
                              example: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/...
                        plan_id:
                          type: integer
                          example: 102
                        web_url:
                          type: string
                          format: uri
                          description: |
                            Absolute URL of the item's detail page in the
                            desktop Onboarding Hub — the same link the
                            desktop timeline renders. Surveys and trainings
                            have no standalone detail page, so they point at
                            the plan page.
                          example: https://officechat-dev.workforce.mangoapps.com/apps/onboarding_hub/tasks/42
                        mobile_url:
                          type: string
                          format: uri
                          description: |
                            Absolute URL of the item's detail page on the
                            mobile-web surface (`/m/apps/onboarding-hub/...`).
                            Only task & document ship a dedicated mobile
                            detail page; every other type falls back to the
                            mobile plan page so the link always resolves.
                          example: https://officechat-dev.workforce.mangoapps.com/m/apps/onboarding-hub/tasks/42
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 2
                      total_count:
                        type: integer
                        example: 30
                      phase:
                        type: string
                        example: all
                      status:
                        type: string
                        example: overdue
                      search:
                        type: string
                        nullable: true
                        example:
        '400':
          description: Unknown phase or status value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: "`invalid_phase` or `invalid_status` depending
                          on which filter was bad."
                        example: invalid_status
                      message:
                        type: string
                        example: 'Unknown status: foo'
                      details:
                        type: object
                        properties:
                          allowed:
                            type: array
                            items:
                              type: string
        '401':
          description: Authentication required.
  "/feeds":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List feed posts (filtered, paginated)
      description: |
        Returns the audience-scoped, published, non-expired feed posts the
        caller can see. Mirrors the web's tabbed feed (PRD 02 §5.1) — same
        visibility, same ordering, same filter set.

        Default ordering: unread posts first, then within each
        unread/read bucket by priority (`must_read` → `operational` →
        `social`) and finally `published_at DESC`. The `pinned` filter
        overrides this with the caller's own `pinned_at DESC` (per-user);
        `scheduled` overrides with `scheduled_at ASC` (soonest first).

        When `filter=all`, the response includes a `meta.unread_counts`
        object with per-scope unread totals (`all`, `must_read`,
        `operational`, `pinned`, `social`, `mentions`) so the client can
        render nav badges without extra round-trips. Each bucket reuses
        the same audience-scoped, published+active visibility constraints
        as the index list.

        The `filter` and the legacy `category` / `must_read` / `segment_id`
        params compose. `topic_id` is independent and stacks with any filter.
      parameters:
      - name: filter
        in: query
        description: |
          Primary filter. Mutually exclusive set; default `all`.
            * `all`         — every visible published post
            * `pinned`      — posts the calling user has personally pinned
                              (per-user — does not include posts pinned by
                              other users). Ordered by the caller's
                              `pinned_at DESC`.
            * `mentions`    — posts where the caller was @-mentioned in any
                              comment on the post. Backed by the
                              `news_feed_mention` notifications index — only
                              comment mentions create notifications, so
                              body-only mentions are NOT included here.
            * `unread`      — visible posts that are unread for the caller RIGHT
                              NOW (no read record, or unread again because of
                              new comments / an owed must-read acknowledgement
                              — the same predicate as `viewer.read == false`
                              and `meta.unread_counts`). A post read since
                              `as_of` was stamped is dropped from the page, so
                              this list never contains a row whose own
                              `viewer.read` is `true`.
            * `must_read`   — priority='must_read'
            * `operational` — priority='operational'
            * `social`      — priority='social'
            * `scheduled`   — caller's OWN posts queued for future publish
                              (status='scheduled'). Bypasses the default
                              published-only filter; ordered by
                              `scheduled_at ASC` (soonest first).
        schema:
          type: string
          enum:
          - all
          - pinned
          - mentions
          - unread
          - must_read
          - operational
          - social
          - scheduled
          default: all
      - name: topic_id
        in: query
        description: Restrict to posts assigned this topic (author or AI source).
          A post also matches when the topic lives on one of its active comments.
        schema:
          type: integer
      - name: category
        in: query
        description: Legacy filter by content_category. Composes with `filter`.
        schema:
          type: string
          enum:
          - operational
          - social
      - name: segment_id
        in: query
        description: Legacy filter — posts targeted at the given audience segment.
        schema:
          type: string
      - name: must_read
        in: query
        description: Legacy boolean filter. Maps to `filter=must_read` if `filter`
          is omitted.
        schema:
          type: boolean
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      - name: as_of
        in: query
        description: |
          Optional ISO-8601 read-state cutoff for the paging session. The
          unread predicate is the primary sort key of an OFFSET-paginated
          list, so marking cards read while paging otherwise re-sorts the
          list under the client's own offsets and page N+1 skips rows that
          slid up. Send back the `meta.as_of` value the first page returned
          and every page is ordered against the same instant.

          Safe to omit: the server holds the snapshot for a short paging
          session per caller and per `filter`, so `page >= 2` reuses the window
          page 1 was ordered against even when the client sends nothing, and a
          request on another `filter` cannot move it. Requesting page 1 (or
          omitting `page`) always re-stamps that filter's snapshot. Values that
          are blank, unparseable, in the future, or older than 12 hours fall
          back to that server-held snapshot (never an error).

          ORDERING ONLY. It does NOT decide what you are shown: the `unread`
          filter's membership is resolved against live read state per page, so
          a post read since the stamp is dropped from the response rather than
          listed as unread. A page may therefore come back shorter than
          `per_page` while `meta.total_count` (the frozen window) still
          describes what is left to page through.
        schema:
          type: string
          format: date-time
      responses:
        '200':
          description: Paginated feed list
          content:
            application/json:
              schema:
                type: object
                required:
                - feeds
                - meta
                properties:
                  feeds:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedSummary"
                  meta:
                    type: object
                    properties:
                      filter:
                        type: string
                        example: all
                      page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 5
                      total_count:
                        type: integer
                        example: 87
                      as_of:
                        type: string
                        format: date-time
                        description: |
                          The read-state cutoff this page was ordered against.
                          Echo it back as the `as_of` query param on page 2+ to
                          hold the window still; request page 1 without it to
                          start a fresh session. Always present.
                        example: '2026-08-31T09:15:00Z'
                      unread_counts:
                        type: object
                        description: |
                          Per-scope unread totals for the caller. Only
                          included when `filter=all`. Reuses the index
                          visibility constraints — same audience, same
                          published+active filter.
                        properties:
                          all:
                            type: integer
                            example: 12
                          must_read:
                            type: integer
                            example: 3
                          operational:
                            type: integer
                            example: 7
                          pinned:
                            type: integer
                            example: 1
                          social:
                            type: integer
                            example: 2
                          mentions:
                            type: integer
                            example: 4
        '401':
          description: Unauthorized — bearer token missing or invalid
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Create a feed post (Update, Question, or Poll)
      description: |
        Publishes a feed post. Mirrors the web composer (PRD 02). Server-side
        guardrails:
          * Out-of-policy content_type silently falls back to `update`
          * Out-of-policy priority (`must_read` without permission) falls back to `operational`
          * `priority=must_read` on a `question` or `poll` falls back to
            `operational` — must-read is reserved for one-way operational
            broadcasts, not interactive prompts
          * Out-of-policy `audience_type=everyone` falls back to `segments`
          * For `question`, the title is derived from the first non-blank line of `body` if omitted
          * For `poll`, `poll_duration_days` (default 7, max 30) sets `closes_at`.
            Pass the string `"never"` to create a poll that never auto-closes
            (`closes_at` is null) — close it later via `POST /feeds/{id}/close_poll`.

        Publish-time fan-out (audience snapshot, recipient notifications,
        webhooks, poll-close scheduling, SME routing, auto-answer) happens
        asynchronously via PublishFeedWorkflowJob — the response returns as
        soon as the row is persisted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feed
              properties:
                feed:
                  type: object
                  properties:
                    content_type:
                      type: string
                      enum:
                      - update
                      - question
                      - poll
                      default: update
                    title:
                      type: string
                      description: Required for poll. Derived from body.first_line
                        for question.
                    body:
                      type: string
                    priority:
                      type: string
                      enum:
                      - must_read
                      - operational
                      - social
                      default: operational
                    audience_type:
                      type: string
                      enum:
                      - everyone
                      - segments
                      default: everyone
                    audience_segment_ids:
                      type: array
                      items:
                        type: string
                    audience_criteria:
                      type: array
                      description: |
                        Structured attribute targeting — the same shapes the
                        Broadcast kind accepts, so one recipient picker serves
                        all four kinds of the Communications composer. Unioned
                        with `extra_user_ids` and any `audience_segment_ids`
                        sent alongside, then RESOLVED at send time onto the
                        feed row (a frozen recipient list, not a stored rule —
                        the fan-out and the acknowledgement denominator are
                        both taken now).

                        Unknown criterion types, and ids belonging to another
                        tenant, are dropped server-side. An `everyone`
                        criterion absorbs the rest of the selection.

                        A structured selection that resolves to NOBODY is a 422
                        (`error.code: no_recipients`), never a silent widening
                        to everyone.
                      items:
                        type: object
                        properties:
                          type:
                            type: string
                            enum:
                            - everyone
                            - department
                            - job_title
                            - location
                            - role
                          ids:
                            type: array
                            items:
                              type: integer
                            description: For `department` / `location`.
                          titles:
                            type: array
                            items:
                              type: string
                            description: For `job_title`.
                          roles:
                            type: array
                            items:
                              type: string
                              enum:
                              - admin
                              - manager
                              - member
                            description: For `role`.
                      example:
                      - type: department
                        ids:
                        - 3
                        - 8
                      - type: role
                        roles:
                        - manager
                    extra_user_ids:
                      type: array
                      items:
                        type: integer
                      description: |
                        Named individuals to include, unioned with
                        `audience_criteria`. Ids outside this business are
                        dropped.
                    policy_id:
                      type: integer
                      description: |
                        The HR policy this Must-Read asks readers to accept
                        (options: `GET /news-feed/policies`; one policy's
                        detail: `GET /news-feed/policies/{id}`). Honored only when
                        `priority` is `must_read` — the other kinds carry no
                        acknowledgement to compare an acceptance against — and
                        re-scoped to this business, so a crafted id cannot
                        attach another tenant's policy. Read back as the
                        `policy` block on the feed payload. `hr_policy_id` is
                        accepted as an alias.
                    expires_at:
                      type: string
                      format: date-time
                    scheduled_at:
                      type: string
                      format: date-time
                    requires_acknowledgement:
                      type: boolean
                    ai_assisted:
                      type: boolean
                    status:
                      type: string
                      enum:
                      - draft
                      - scheduled
                      - published
                      description: Optional. `published` is the default; `draft` saves
                        without publishing.
                    poll_duration_days:
                      description: Days until the poll auto-closes (1-30, default
                        7), or the string "never" for a poll that stays open until
                        closed manually (closes_at null).
                      default: 7
                      oneOf:
                      - type: integer
                        minimum: 1
                        maximum: 30
                      - type: string
                        enum:
                        - never
                    poll_options_attributes:
                      type: array
                      description: Required when content_type=poll. 2-20 options.
                      items:
                        type: object
                        properties:
                          text:
                            type: string
                          position:
                            type: integer
                    poll_config_attributes:
                      type: object
                      properties:
                        voting_mode:
                          type: string
                          enum:
                          - single
                          - multiple
                        is_anonymous:
                          type: boolean
                        result_visibility:
                          type: string
                          enum:
                          - live
                          - hidden_until_close
                        allow_change_vote:
                          type: boolean
                          default: true
                          description: When false, voters cannot change their vote
                            after submitting.
                        allow_comments:
                          type: boolean
                          default: true
                          description: 'When false, the post/poll is created with
                            comments turned off (no comment input shown, returned
                            as `poll.allow_comments: false`). Mirrors the read shape,
                            where this flag is surfaced inside the `poll` block. May
                            also be sent top-level as `feed[allow_comments]`. Backed
                            by the feed-level `comments_enabled` column, distinct
                            from the reversible moderation discussion-close.'
                    feed_media_ids:
                      type: array
                      items:
                        type: integer
                      description: |
                        Ids of FeedMedia rows pre-uploaded via `POST /media`
                        and finalized via `POST /media/{id}/complete`. Each
                        row must still be orphan (not already attached) and
                        owned by the caller; gallery order matches the
                        order of ids in this array. Reject reasons surface
                        as 422 with `error.message` describing which ids
                        failed.
                rejected_topics:
                  type: array
                  items:
                    type: string
                  description: Topic names the author dismissed in the composer suggestion
                    strip (PRD 11).
      responses:
        '201':
          description: Post created
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
        '400':
          description: Missing `feed` parameter
        '401':
          description: Unauthorized
        '403':
          description: Composer disabled for this business
        '422':
          description: |
            Validation failed, or the structured audience selection
            (`audience_criteria` / `extra_user_ids`) resolved to nobody —
            `error.code: no_recipients`. The post is NOT created and the
            audience is never silently widened to everyone.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - validation_failed
                        - no_recipients
                        example: validation_failed
                      message:
                        type: string
                      details:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
  "/feeds/{id}":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Get a feed with the first page of top-level comments
      description: |
        Returns the feed detail payload + the first page (20) of top-level
        comments with replies inlined. The feed payload embeds the latest
        AI discussion summary (`ai_summary`) and, for polls, the latest AI
        outcome summary (`poll_summary`) so mobile clients render the same
        cards as the web view in a single roundtrip. Both follow the
        gating rules of their web partials — see `NewsFeedDetail`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Feed detail + embedded comments page
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
                  comments:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          "$ref": "#/components/schemas/NewsFeedComment"
                      meta:
                        type: object
                        properties:
                          current_page:
                            type: integer
                          per_page:
                            type: integer
                          total_count:
                            type: integer
                          total_pages:
                            type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Edit a feed post
      description: |
        Update a feed post. Mirrors the web composer's edit semantics from
        `Apps::NewsFeed::FeedsController#update`:

          * **Draft / scheduled posts** — author may update the full composer
            param set (title, body, priority, audience, scheduling, poll
            configuration). Submitting `status=draft` keeps it a draft; any
            other value resolves to `published` (immediate) or `scheduled`
            (when `scheduled_at` is in the future).
          * **Published posts** — only `title` and `body` are accepted, and
            the call is gated on BOTH the 15-min author edit window
            (FR-02-17) AND the admin "content_editing" feature flag. Polls
            are immutable once published (FR-02-18).

        Author-only — non-authors get 403. Outside the edit window or with
        the admin toggle off, the call returns 403.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feed
              properties:
                feed:
                  type: object
                  description: |
                    Published posts: `{title, body}` only.
                    Draft/scheduled: full composer param set (see POST /feeds).
                  properties:
                    title:
                      type: string
                    body:
                      type: string
                    priority:
                      type: string
                      enum:
                      - must_read
                      - operational
                      - social
                    audience_type:
                      type: string
                      enum:
                      - everyone
                      - segments
                    audience_segment_ids:
                      type: array
                      items:
                        type: string
                    scheduled_at:
                      type: string
                      format: date-time
                      nullable: true
                    expires_at:
                      type: string
                      format: date-time
                      nullable: true
                    requires_acknowledgement:
                      type: boolean
                    status:
                      type: string
                      enum:
                      - draft
                      - scheduled
                      - published
      responses:
        '200':
          description: Updated feed
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
        '400':
          description: Missing `feed` parameter
        '401':
          description: Unauthorized
        '403':
          description: |
            Caller is not the author, edit window has elapsed, the admin
            content-editing flag is off, or the post is a published poll.
        '404':
          description: Feed not found
        '422':
          description: Validation failure
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: validation_failed
                      message:
                        type: string
                      details:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Delete a feed post
      description: |
        Delete a feed post. Author-only — non-authors get 403. Mirrors the
        web composer's `#destroy`: enqueues `NewsFeed::MediaCleanupJob` to
        sweep the attached media and clears the author's draft row when
        the deleted feed was itself a draft. Discussion closes, reactions,
        comments, read records, and acknowledgements cascade via the
        model's `dependent: :destroy` associations.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Feed deleted
        '401':
          description: Unauthorized
        '403':
          description: Caller is not the author
        '404':
          description: Feed not found
  "/feeds/{id}/mute":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mute notifications for a feed
      description: |
        Mute notifications about activity on this feed (new comments,
        reactions, etc.) for the calling user. Idempotent — repeat POSTs
        return the same `muted_at` timestamp. Visible-to-caller required.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Muted (or already muted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  muted_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '404':
          description: Feed not found
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unmute notifications for a feed
      description: |
        Remove the caller's notification mute on this feed. Idempotent —
        returns 204 even if no mute existed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Mute removed (or none existed)
        '401':
          description: Unauthorized
        '404':
          description: Feed not found
  "/feeds/{id}/close_discussion":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Close discussion on a feed
      description: |
        Close the discussion on a feed (no new comments, no new replies).
        Authorization (PRD 04 FR-04-10): the author may close their own
        feed's discussion, and an admin may close any feed's discussion.
        Existing comments remain visible.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Discussion closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  discussion:
                    type: object
                    properties:
                      open:
                        type: boolean
                      closed_at:
                        type: string
                        format: date-time
                        nullable: true
                      closed_by_user_id:
                        type: integer
                        nullable: true
                      reopened_at:
                        type: string
                        format: date-time
                        nullable: true
                      reopened_by_user_id:
                        type: integer
                        nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Caller is neither author nor admin
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: Feed not found
        '422':
          description: Discussion is already closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/feeds/{id}/reopen_discussion":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Reopen a closed discussion
      description: |
        Reopen a previously-closed discussion. Authorization (PRD 04
        FR-04-11): admin-only — authors cannot reopen their own closed
        discussions.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Discussion reopened
          content:
            application/json:
              schema:
                type: object
                properties:
                  discussion:
                    type: object
                    properties:
                      open:
                        type: boolean
                      closed_at:
                        type: string
                        format: date-time
                        nullable: true
                      closed_by_user_id:
                        type: integer
                        nullable: true
                      reopened_at:
                        type: string
                        format: date-time
                        nullable: true
                      reopened_by_user_id:
                        type: integer
                        nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Caller is not an admin
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: Feed not found
        '422':
          description: Discussion is not closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/feeds/{id}/publish":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Publish a scheduled feed immediately
      description: |
        Publish-now for a feed currently in `status=scheduled`. Author-only.
        Mirrors the web composer's "Publish now" action: flips status to
        `published`, clears `scheduled_at`, stamps `published_at`, and
        triggers the same audience snapshot + recipient fan-out workflow
        (`NewsFeed::PublishFeedWorkflowJob`).

        Calling on a feed that isn't in `scheduled` state returns 422.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Feed published
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
        '401':
          description: Unauthorized
        '403':
          description: Caller is not the author
        '404':
          description: Feed not found
        '422':
          description: Feed is not in `scheduled` state
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: not_scheduled
                      message:
                        type: string
  "/feeds/{id}/pin":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Pin a feed for the calling user
      description: |
        Personal pin — the post surfaces in the calling user's `filter=pinned`
        listing only. Pinning has no effect on any other viewer's feed. Any
        user who can see the post can pin it; 403 is returned only when the
        caller is not in the feed's audience.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Pinned for the calling user
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  pinned:
                    type: boolean
                    example: true
                    description: Per-user — reflects the calling user's pin state
                      only.
                  pinned_at:
                    type: string
                    format: date-time
                    description: When the calling user pinned the post.
        '401':
          description: Unauthorized
        '403':
          description: Caller is not in the feed's audience
        '404':
          description: Feed not found
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unpin a feed for the calling user
      description: |
        Removes the calling user's personal pin on the post. Idempotent —
        succeeds whether or not a pin row existed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Unpinned for the calling user
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  pinned:
                    type: boolean
                    example: false
        '401':
          description: Unauthorized
        '403':
          description: Caller is not in the feed's audience
        '404':
          description: Feed not found
  "/feeds/{id}/comments":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List comments on a feed
      description: |
        Returns the active comments page-by-page. When `parent_comment_id`
        is set, returns the replies of that comment instead of top-level
        ones.

        Each top-level comment includes its direct replies inline under
        `replies[]` (depth-1, ordered by `created_at` ascending). Both
        the top-level comment and each reply carry their own `media[]`
        attachments, so clients do not need a follow-up request per
        comment to render the thread.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: parent_comment_id
        in: query
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: Paginated comments
          content:
            application/json:
              schema:
                type: object
                properties:
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedComment"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Create a comment on a feed
      description: |
        Creates a top-level comment or a reply. PRD 04 Phase 16 adds optional
        attachments via `comment_media_ids[]`: mobile uploads orphan media
        through `POST /media` + `POST /media/{id}/complete` first, then sends
        the resulting ids here. The server claims them inside the create
        transaction (locked `FOR UPDATE`); any rejection — wrong owner,
        already attached, soft-deleted, over the per-comment cap of 10 —
        rolls the whole comment back so partial attachment sets are
        impossible. `body` may be blank when at least one attachment is
        provided ("attachment-only" comments).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: |
                    Comment text (1..2000 chars). May be blank only when
                    `comment_media_ids` is non-empty.
                parent_comment_id:
                  type: integer
                  description: Omit for top-level; provide to reply.
                ai_assisted:
                  type: boolean
                comment_media_ids:
                  type: array
                  items:
                    type: integer
                  description: |
                    Ids of FeedMedia rows pre-uploaded via `POST /media`
                    and finalized via `POST /media/{id}/complete`. Each
                    row must still be orphan (not yet attached) and owned
                    by the caller; gallery order matches the order of ids
                    in this array. Cap of 10 attachments per comment.
                    Mirror of `feed_media_ids` on `POST /feeds`. Reject
                    reasons surface as 422 with `error.message`.
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/NewsFeedComment"
        '401':
          description: Unauthorized
        '403':
          description: Discussion is closed
        '404':
          description: Feed not found
        '422':
          description: Validation failed (body length, depth exceeded, media not owned
            / already claimed / cap exceeded, etc.)
  "/feeds/{id}/reactions":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List reactors on a feed (flat + grouped-by-emoji)
      description: |
        Returns the reactors on a feed in two shapes:

          * `reactors` — flat list, ordered most-recent first; used by the
            mobile popover row.
          * `grouped`  — same reactors bucketed by `emoji_key` and sorted
            by count desc; used by the reactor sheet's per-emoji tabs.

        Capped at the most recent 200 reactors.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Flat + grouped reactor lists
          content:
            application/json:
              schema:
                type: object
                required:
                - reactors
                - grouped
                - total
                properties:
                  total:
                    type: integer
                    minimum: 0
                  reactors:
                    type: array
                    items:
                      type: object
                      properties:
                        emoji_key:
                          type: string
                        tier:
                          type: string
                        reacted_at:
                          type: string
                          format: date-time
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              nullable: true
                            avatar_url:
                              type: string
                              nullable: true
                              format: uri
                  grouped:
                    type: array
                    items:
                      type: object
                      properties:
                        emoji_key:
                          type: string
                        tier:
                          type: string
                        count:
                          type: integer
                          minimum: 0
                        users:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                                nullable: true
                              avatar_url:
                                type: string
                                nullable: true
                                format: uri
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Add or swap the caller's reaction on a feed
      description: |
        At most one reaction per user per feed. POSTing a different emoji
        swaps in place; POSTing the same emoji is idempotent. To remove,
        send DELETE.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji_key
              properties:
                emoji_key:
                  type: string
                  description: One of the 14 allowed NewsFeed emoji keys (e.g., `like`,
                    `love`, `celebrate`, `insightful`).
      responses:
        '200':
          description: Aggregate reaction state after the update
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: Invalid emoji_key
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Remove the caller's reaction on a feed
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Reaction removed (or already absent — idempotent)
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
  "/feeds/{id}/correct_answer":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Read the current verified answer on a Question post
      description: |
        Returns the marked correct answer or `null` when none has been
        marked yet. Same shape as the embedded `correct_answer` block on
        `GET /feeds/{id}`, so clients can reuse one renderer.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Current correct answer (or null)
          content:
            application/json:
              schema:
                type: object
                properties:
                  correct_answer:
                    nullable: true
                    allOf:
                    - "$ref": "#/components/schemas/NewsFeedCorrectAnswer"
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience, or correct-answer marking
            disabled for the business
        '404':
          description: Feed not found
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark a comment as the verified answer (Question posts only)
      description: |
        Only the post author or a News Feed admin may mark the correct answer.
        Locks once the discussion is closed. Replacing an existing marked
        answer is a single transactional swap (delete + insert).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment_id
              properties:
                comment_id:
                  type: integer
      responses:
        '200':
          description: Verified answer marked
          content:
            application/json:
              schema:
                type: object
                properties:
                  correct_answer:
                    "$ref": "#/components/schemas/NewsFeedCorrectAnswer"
        '401':
          description: Unauthorized
        '403':
          description: Caller not authorized to mark the correct answer, or correct-answer
            marking disabled for the business
        '404':
          description: Feed or comment not found
        '422':
          description: |
            Returned when the feed is not a Question, the discussion is closed,
            or the chosen comment has been deleted. `error.code` discriminates.
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unmark the verified answer on a Question post
      description: |
        Only the post author or a News Feed admin may unmark. Idempotent —
        returns `correct_answer: null` even when nothing was marked.
        Locks once the discussion is closed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Verified answer removed (or already absent — idempotent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  correct_answer:
                    nullable: true
                    type: object
                    example:
        '401':
          description: Unauthorized
        '403':
          description: Caller not authorized to unmark, or correct-answer marking
            disabled for the business
        '404':
          description: Feed not found
        '422':
          description: Discussion is closed
  "/feeds/{id}/mark_seen":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark a feed as read by the caller
      description: |
        Upserts a NewsFeed::ReadRecord. `read_at` is the first-view timestamp;
        `last_seen_at` is the most-recent-view timestamp (used by the "new
        replies since" preview card).

        A must-read post logs NO view until the caller acknowledges it. While
        an acknowledgement is still owed the call is accepted but records
        nothing, and the response carries `logged: false` with
        `acknowledgement_required: true` and null timestamps. Acknowledging
        the post logs the view; every later call then behaves normally. The
        gate lifts once the acknowledgement window has closed, since nobody
        can acknowledge an expired must-read.

        Pass `scroll_depth_pct` when the client can measure how much of the
        post body the reader actually saw. At >= 80 the read is promoted to a
        QUALIFIED read (PRD 18 FR-18-01, set-once) and counts toward the Read
        rate tile in post analytics; omit it and the call records a plain
        impression, which appears in the viewers roster but not in Read rate.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: scroll_depth_pct
        in: query
        description: Percentage of the post body that entered the viewport, 0-100.
          Values >= 80 promote this read to a qualified read.
        schema:
          type: number
          minimum: 0
          maximum: 100
      responses:
        '200':
          description: Read record persisted, or declined pending acknowledgement
          content:
            application/json:
              schema:
                type: object
                properties:
                  logged:
                    type: boolean
                    description: False when nothing was recorded because the caller
                      still owes an acknowledgement on a must-read post.
                    example: true
                  acknowledgement_required:
                    type: boolean
                    description: Sent as true only when `logged` is false — the caller
                      clears it by acknowledging the post.
                  read_at:
                    type: string
                    format: date-time
                    nullable: true
                  last_seen_at:
                    type: string
                    format: date-time
                    nullable: true
                  qualified_read_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: Set once, the first time this feed was read with
                      scroll_depth_pct >= 80. Null while the caller has only ever
                      recorded impressions.
                  scroll_depth_pct:
                    type: number
                    nullable: true
                    description: Scroll depth stored on the qualifying read, 0-100.
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark a feed as unread (remove the caller's read record)
      description: Idempotent — returns 200 even when there is no read record.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Read record cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  read:
                    type: boolean
                    example: false
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
  "/feeds/mark_all_read":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark every visible feed as read for the caller
      description: |
        Bulk-marks every audience-visible, published, non-expired feed as
        read for the caller. Inserts a NewsFeed::ReadRecord row (with
        `read_at` and `last_seen_at` set to now) for each previously
        unread feed; feeds the caller has already read are left alone.

        Must-read posts the caller has not acknowledged are EXCLUDED and are
        not counted in `marked_count` — a pending acknowledgement cannot be
        cleared in bulk, only by acknowledging the post itself.

        Idempotent — safe to call repeatedly. Returns the number of
        feeds newly marked so the client can update its unread badges
        optimistically.
      responses:
        '200':
          description: Bulk read receipts persisted
          content:
            application/json:
              schema:
                type: object
                required:
                - marked_count
                - marked_at
                properties:
                  marked_count:
                    type: integer
                    description: Number of feeds newly marked as read on this call.
                    example: 12
                  marked_at:
                    type: string
                    format: date-time
                    description: Timestamp assigned to the inserted read records.
        '401':
          description: Unauthorized
  "/feeds/{id}/viewers":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List users who have viewed the feed (read receipts)
      description: |
        Read-receipt roster for a feed post. Returns the users who have
        viewed the feed (NewsFeed::ReadRecord rows), ordered
        most-recently-viewed first and paginated. Each row carries the
        canonical v1 user shape (id / name / avatar_url) plus the row
        subtitle (job title / office location) and the most-recent view
        time. Visible to anyone who can see the feed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Viewers listed (most-recently-viewed first)
          content:
            application/json:
              schema:
                type: object
                required:
                - viewers
                - meta
                properties:
                  viewers:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - viewed_at
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Viewer's job title (row subtitle — what the
                            request calls "position").
                        location:
                          type: string
                          nullable: true
                          description: Viewer's office location (row subtitle).
                        viewed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: COALESCE(last_seen_at, read_at) — most-recent
                            view time.
                  meta:
                    type: object
                    required:
                    - unique_view_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      unique_view_count:
                        type: integer
                        description: Distinct viewers (equals total_count).
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
  "/feeds/{id}/acknowledge":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Acknowledge a must-read feed
      description: |
        Records the caller's acknowledgement of a must-read post. Idempotent —
        repeat acknowledgements return the original `acknowledged_at`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  acknowledged_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '404':
          description: Feed not found
        '422':
          description: Feed is not must-read, or has expired
  "/feeds/{id}/acknowledgements":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List the must-read compliance roster (acknowledged or pending)
      description: |
        Compliance roster for a must-read post. A single endpoint backs
        both the "Acknowledged" and "Pending" tabs of the UI — switch
        between them via the `status` query param.

        Each row carries the canonical v1 user shape (id / name / email /
        avatar_url) plus the row subtitle (job title — what callers refer
        to as "position" — and office location) and an `acknowledged_at`
        timestamp (set for the `acknowledged` list, `null` for `pending`).

        Modes (selected by `status`, default `acknowledged`):
          * `acknowledged` — users who have recorded an acknowledgement
            (`NewsFeed::AcknowledgementRecord` rows). Ordered
            most-recently-acknowledged first.
          * `pending` — users in the feed's audience (resolved via
            `NewsFeed::AudienceResolver`) who have NOT yet acknowledged.
            The feed author is excluded (mirrors the bulk-reminder
            fan-out rule). Ordered by user id.

        Authorization mirrors `POST /feeds/{id}/send_reminder`: only the
        feed author, a business admin-or-above, or a `news-feed` app
        admin may view the roster. The feed must have
        `priority='must_read'` (422 otherwise).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: status
        in: query
        required: false
        description: |
          Which slice of the roster to return. Default `acknowledged`.
          Any other value returns 422 `invalid_status`.
        schema:
          type: string
          enum:
          - acknowledged
          - pending
          default: acknowledged
      - name: page
        in: query
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: |
            Roster slice for the requested `status`. Ordering and the
            meaning of `acknowledged_at` depend on `status` (see
            description).
          content:
            application/json:
              schema:
                type: object
                required:
                - users
                - meta
                properties:
                  users:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - acknowledged_at
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        email:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: User's job title (row subtitle — what the request
                            calls "position").
                        location:
                          type: string
                          nullable: true
                          description: User's office location (row subtitle).
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: |
                            When the user acknowledged this must-read post.
                            Always `null` when `status=pending`.
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller is not author / admin / app-admin
        '404':
          description: Feed not found
        '422':
          description: |
            Feed is not a must-read post, OR the `status` query param is
            not one of `acknowledged` / `pending`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - invalid_status
                      message:
                        type: string
  "/feeds/{id}/send_reminder":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Send a manual acknowledgement reminder (bulk or single user)
      description: |
        Triggers `NewsFeed::MustReadReminderJob` (the same job the automated
        scheduler enqueues) to remind users who haven't yet acknowledged
        this must-read post. Two modes, selected by the optional `user_id`
        field in the request body:

        **Bulk mode (omit `user_id`)** — mirrors the web
        `Apps::NewsFeed::AcknowledgementsController#send_reminder` exactly:

          * The feed must have `priority='must_read'` — 422 otherwise.
          * Caller must be the feed author, a business admin-or-above, or
            an app admin for `news-feed` — 403 otherwise.
          * 24-hour cooldown: only one bulk reminder per feed per day,
            tracked by `feeds.last_reminder_sent_at`. Subsequent calls
            inside the window return 429.

        On success, stamps `last_reminder_sent_at = Time.current` and
        returns the new value as `sent_at`. Fan-out reaches every audience
        user who hasn't already acknowledged, hasn't muted the post, and
        isn't the author.

        **Targeted mode (`user_id` present)** — single-recipient nudge,
        mirroring the broadcasts `POST /broadcasts/:id/remind` shape:

          * Same authz gates as bulk.
          * The target user must be in the same business AND in the feed's
            audience (422 `not_a_recipient` otherwise).
          * Cannot target the feed author (422 `cannot_remind_author`).
          * Cannot target a user who has already acknowledged (422
            `already_acknowledged`).
          * Does **not** touch the feed-level 24h cooldown — single-user
            reminders are independent of the bulk path.
          * Mute is intentionally overridden — an explicit single-user
            nudge wins over the recipient's per-post mute.

        On success, returns `{ sent_at, user_id }`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                  description: |
                    Optional. When omitted, bulk reminder to every pending
                    audience user. When present, targeted reminder to just
                    that user.
      responses:
        '200':
          description: Reminder enqueued
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  description: Bulk mode response
                  properties:
                    sent_at:
                      type: string
                      format: date-time
                - type: object
                  description: Targeted mode response
                  properties:
                    sent_at:
                      type: string
                      format: date-time
                    user_id:
                      type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller is not author / admin / app-admin
        '404':
          description: Feed not found
        '422':
          description: |
            Feed is not must-read, OR (targeted mode) the user_id is invalid /
            not a recipient / is the author / has already acknowledged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - user_not_found
                        - not_a_recipient
                        - cannot_remind_author
                        - already_acknowledged
                      message:
                        type: string
        '429':
          description: Bulk mode only — reminder already sent in the last 24 hours
  "/comments/{id}":
    put:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Edit a comment
      description: |
        Update a comment's body. Mirrors `NewsFeed::CommentsService.edit`:

          * **Author-only** — non-authors get 403.
          * Gated on the admin "Allow content editing" feature flag — when
            the flag is off, all edits return 403.
          * Edits are restricted to the 15-minute author edit window from
            the comment's `created_at` (FR-04-13). Expired window returns
            422 with `error.code='edit_window_expired'`.
          * Soft-deleted comments cannot be edited (403).

        On success, sets `edited_at = Time.current` and returns the full
        comment payload (same shape as the list/show endpoints).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: New comment body (1..2000 chars).
      responses:
        '200':
          description: Updated comment
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/NewsFeedComment"
        '401':
          description: Unauthorized
        '403':
          description: |
            Caller is not the author, the content-editing flag is off, or
            the comment is soft-deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: not_authorized
                      message:
                        type: string
        '404':
          description: Comment not found
        '422':
          description: Edit window expired OR validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: edit_window_expired
                      message:
                        type: string
                      details:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Soft-delete a comment
      description: |
        Soft-deletes a comment (`status='deleted'`, body cleared). Mirrors
        `NewsFeed::CommentsService.delete`:

          * Allowed for the comment **author** OR any **business
            admin-or-above** — non-authors / non-admins get 403.
          * Writes a `NewsFeed::AuditLog` row (`delete_own_comment` or
            `admin_delete_comment`) for moderation history.
          * Replies remain visible underneath the now-`[deleted comment]`
            placeholder — child comments are not cascaded.

        Returns 204 on success.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Comment soft-deleted
        '401':
          description: Unauthorized
        '403':
          description: Caller is not the author and not an admin
        '404':
          description: Comment not found
  "/feeds/{id}/close_poll":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Close a poll now
      description: |
        Manually close an open poll ahead of (or in the absence of) its
        scheduled `closes_at`. This is the only way to close a poll created
        with `poll_duration_days: "never"`. One-way — there is no reopen, and
        a closed poll's results become final and visible to everyone.
        Authorization (PRD 05): the feed author or an admin.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Poll closed — returns the updated poll block
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll:
                    type: object
                    description: |
                      Poll state embedded on Poll-type feeds. Returned on both list rows
                      and the detail endpoint. Option labels (`options[].id/text/position`)
                      are included on both so cards can render the option list from the
                      list response; per-option vote counts (`options[].votes`) are
                      detail-only to keep list payloads light.
                    properties:
                      closes_at:
                        type: string
                        format: date-time
                        nullable: true
                      voting_mode:
                        type: string
                        nullable: true
                        enum:
                        - single
                        - multi
                        - ranked
                        description: |
                          Controls how `my_vote_option_ids` should be interpreted:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order is not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                      is_anonymous:
                        type: boolean
                        nullable: true
                      result_visibility:
                        type: string
                        nullable: true
                        enum:
                        - live
                        - hidden_until_close
                      allow_change_vote:
                        type: boolean
                        nullable: true
                      allow_comments:
                        type: boolean
                        description: |
                          Whether a comment may be posted on the poll right now
                          (`Feed#comments_allowed?`). Folds the author's create-time
                          comment setting (the feed-level `comments_enabled` column) together
                          with the moderation discussion-close state, so poll cards gate their
                          comment input from one field. Set it at create/update time via
                          `poll_config_attributes[allow_comments]` (or top-level
                          `feed[allow_comments]`).
                      is_closed:
                        type: boolean
                        description: True when poll_config.closes_at has elapsed.
                      option_count:
                        type: integer
                        minimum: 0
                        description: Number of poll options.
                      my_vote_option_ids:
                        type: array
                        description: |
                          The caller's voted option_ids. Always present; empty array when
                          the caller has not voted. Interpret via `voting_mode`:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                        items:
                          type: integer
                      results_visible:
                        type: boolean
                        description: |
                          Detail-only. True when the caller is allowed to see the
                          per-option vote breakdown; false when results are gated.

                          Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
                          surface:
                            * Closed poll → true (everyone)
                            * Author or business admin → true (always)
                            * Open + `result_visibility: hidden_until_close` → false
                            * Open + `result_visibility: live`, caller has voted → true
                            * Open + `result_visibility: live`, caller has not voted → false

                          When false, `total_votes` and each `options[].votes` /
                          `options[].percent` are returned as `null` so clients can render
                          a "results hidden" state without inferring whether anyone has
                          voted yet.
                      total_votes:
                        type: integer
                        minimum: 0
                        nullable: true
                        description: |
                          Aggregate count of distinct voters (sum of active PollVote rows).
                          Detail-only — returned on `GET /feeds/{id}` and on the
                          `/feeds/{id}/poll_votes` responses, omitted from list rows.
                          `null` when `results_visible` is false.
                      options:
                        type: array
                        description: |
                          Poll options. `id`, `text`, and `position` are returned on both
                          list rows and the detail endpoint. `votes` (per-option count)
                          and `percent` (0–100, rounded) are detail-only — list rows omit
                          them. Both are `null` when `results_visible` is false.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            text:
                              type: string
                            position:
                              type: integer
                            votes:
                              type: integer
                              nullable: true
                              description: Detail endpoint only. `null` when results
                                are gated.
                            percent:
                              type: integer
                              minimum: 0
                              maximum: 100
                              nullable: true
                              description: |
                                Detail endpoint only. Share of `total_votes` cast for this
                                option, rounded to the nearest integer (0–100). `null` when
                                results are gated. Matches the value rendered by the web
                                `_poll` partial so mobile and web stay in lockstep.
        '401':
          description: Unauthorized
        '403':
          description: Caller is neither author nor admin
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: Feed not found
        '422':
          description: Feed is not a poll, or the poll is already closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: poll_already_closed
                      message:
                        type: string
  "/feeds/{id}/poll_votes":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Cast a vote on a poll feed
      description: |
        Records the caller's vote on a poll. Idempotent — if the caller has
        already voted on this poll, returns the existing vote with
        `already_voted: true` (no new row, no counter change).

        For `voting_mode: single` send `poll_option_id`. For `voting_mode:
        multi` send `poll_option_ids` (unordered array). For `voting_mode:
        ranked` send `poll_option_ids` ordered by preference (top choice
        first). Returns the full updated `poll` block (same shape as
        `GET /api/v1/feeds/{id}` → `poll`), including per-option vote counts,
        percentages, and `total_votes` so clients can swap the embedded poll
        on a feed card without a follow-up fetch.

        For `voting_mode: ranked`, each option additionally carries
        `rank_distribution` (an array of counts — voters who ranked it #1,
        #2, …), `score` (Borda points; higher = stronger preference), and
        `my_rank` (the caller's 1-based rank for that option). The `options`
        array is returned in standing order (highest `score` first), and
        `votes`/`percent` report each option's FIRST-preference tally.

        Result visibility (PRD 05 FR-05-06 / FR-05-10) mirrors the web
        surface — `results_visible` reports whether the breakdown is
        unlocked for this caller. When `false`, `total_votes` and each
        option's `votes`/`percent` (and `rank_distribution`/`score` for
        ranked) are returned as `null`. The vote response itself always sees
        results in `live` mode (the caller just became a voter);
        `hidden_until_close` keeps the breakdown gated until the poll closes
        for non-author/non-admin callers.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                poll_option_id:
                  type: integer
                  description: For `single` voting_mode polls.
                poll_option_ids:
                  type: array
                  items:
                    type: integer
                  description: For `multi` voting_mode polls (unordered) and `ranked`
                    voting_mode polls (ordered by preference, top choice first).
      responses:
        '200':
          description: Vote already recorded (idempotent replay)
          content:
            application/json:
              schema:
                "$ref": "#/paths/~1feeds~1{id}~1poll_votes/post/responses/201/content/application~1json/schema"
        '201':
          description: Vote recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - already_voted
                - poll
                properties:
                  already_voted:
                    type: boolean
                  poll:
                    type: object
                    description: |
                      Poll state embedded on Poll-type feeds. Returned on both list rows
                      and the detail endpoint. Option labels (`options[].id/text/position`)
                      are included on both so cards can render the option list from the
                      list response; per-option vote counts (`options[].votes`) are
                      detail-only to keep list payloads light.
                    properties:
                      closes_at:
                        type: string
                        format: date-time
                        nullable: true
                      voting_mode:
                        type: string
                        nullable: true
                        enum:
                        - single
                        - multi
                        - ranked
                        description: |
                          Controls how `my_vote_option_ids` should be interpreted:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order is not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                      is_anonymous:
                        type: boolean
                        nullable: true
                      result_visibility:
                        type: string
                        nullable: true
                        enum:
                        - live
                        - hidden_until_close
                      allow_change_vote:
                        type: boolean
                        nullable: true
                      allow_comments:
                        type: boolean
                        description: |
                          Whether a comment may be posted on the poll right now
                          (`Feed#comments_allowed?`). Folds the author's create-time
                          comment setting (the feed-level `comments_enabled` column) together
                          with the moderation discussion-close state, so poll cards gate their
                          comment input from one field. Set it at create/update time via
                          `poll_config_attributes[allow_comments]` (or top-level
                          `feed[allow_comments]`).
                      is_closed:
                        type: boolean
                        description: True when poll_config.closes_at has elapsed.
                      option_count:
                        type: integer
                        minimum: 0
                        description: Number of poll options.
                      my_vote_option_ids:
                        type: array
                        description: |
                          The caller's voted option_ids. Always present; empty array when
                          the caller has not voted. Interpret via `voting_mode`:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                        items:
                          type: integer
                      results_visible:
                        type: boolean
                        description: |
                          Detail-only. True when the caller is allowed to see the
                          per-option vote breakdown; false when results are gated.

                          Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
                          surface:
                            * Closed poll → true (everyone)
                            * Author or business admin → true (always)
                            * Open + `result_visibility: hidden_until_close` → false
                            * Open + `result_visibility: live`, caller has voted → true
                            * Open + `result_visibility: live`, caller has not voted → false

                          When false, `total_votes` and each `options[].votes` /
                          `options[].percent` are returned as `null` so clients can render
                          a "results hidden" state without inferring whether anyone has
                          voted yet.
                      total_votes:
                        type: integer
                        minimum: 0
                        nullable: true
                        description: |
                          Aggregate count of distinct voters (sum of active PollVote rows).
                          Detail-only — returned on `GET /feeds/{id}` and on the
                          `/feeds/{id}/poll_votes` responses, omitted from list rows.
                          `null` when `results_visible` is false.
                      options:
                        type: array
                        description: |
                          Poll options. `id`, `text`, and `position` are returned on both
                          list rows and the detail endpoint. `votes` (per-option count)
                          and `percent` (0–100, rounded) are detail-only — list rows omit
                          them. Both are `null` when `results_visible` is false.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            text:
                              type: string
                            position:
                              type: integer
                            votes:
                              type: integer
                              nullable: true
                              description: Detail endpoint only. `null` when results
                                are gated.
                            percent:
                              type: integer
                              minimum: 0
                              maximum: 100
                              nullable: true
                              description: |
                                Detail endpoint only. Share of `total_votes` cast for this
                                option, rounded to the nearest integer (0–100). `null` when
                                results are gated. Matches the value rendered by the web
                                `_poll` partial so mobile and web stay in lockstep.
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: Not a poll, poll closed, or invalid option selection
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Change a previously cast poll vote
      description: |
        Changes the caller's vote on a poll. Gated on
        `poll_config.allow_change_vote` — returns `422` if the poll forbids
        vote changes or the caller has not yet voted. Atomic: the previous
        active vote row is superseded and the new one is created in a single
        transaction. The voter total is unchanged; only the per-option split
        shifts. Returns the same payload shape as POST — including
        `results_visible`, per-option `percent`, and `total_votes` under the
        same gating rules.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                poll_option_id:
                  type: integer
                  description: For `single` voting_mode polls.
                poll_option_ids:
                  type: array
                  items:
                    type: integer
                  description: For `multi` voting_mode polls (unordered) and `ranked`
                    voting_mode polls (ordered by preference, top choice first).
      responses:
        '200':
          description: Vote changed
          content:
            application/json:
              schema:
                type: object
                required:
                - already_voted
                - poll
                properties:
                  already_voted:
                    type: boolean
                  poll:
                    type: object
                    description: |
                      Poll state embedded on Poll-type feeds. Returned on both list rows
                      and the detail endpoint. Option labels (`options[].id/text/position`)
                      are included on both so cards can render the option list from the
                      list response; per-option vote counts (`options[].votes`) are
                      detail-only to keep list payloads light.
                    properties:
                      closes_at:
                        type: string
                        format: date-time
                        nullable: true
                      voting_mode:
                        type: string
                        nullable: true
                        enum:
                        - single
                        - multi
                        - ranked
                        description: |
                          Controls how `my_vote_option_ids` should be interpreted:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order is not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                      is_anonymous:
                        type: boolean
                        nullable: true
                      result_visibility:
                        type: string
                        nullable: true
                        enum:
                        - live
                        - hidden_until_close
                      allow_change_vote:
                        type: boolean
                        nullable: true
                      allow_comments:
                        type: boolean
                        description: |
                          Whether a comment may be posted on the poll right now
                          (`Feed#comments_allowed?`). Folds the author's create-time
                          comment setting (the feed-level `comments_enabled` column) together
                          with the moderation discussion-close state, so poll cards gate their
                          comment input from one field. Set it at create/update time via
                          `poll_config_attributes[allow_comments]` (or top-level
                          `feed[allow_comments]`).
                      is_closed:
                        type: boolean
                        description: True when poll_config.closes_at has elapsed.
                      option_count:
                        type: integer
                        minimum: 0
                        description: Number of poll options.
                      my_vote_option_ids:
                        type: array
                        description: |
                          The caller's voted option_ids. Always present; empty array when
                          the caller has not voted. Interpret via `voting_mode`:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                        items:
                          type: integer
                      results_visible:
                        type: boolean
                        description: |
                          Detail-only. True when the caller is allowed to see the
                          per-option vote breakdown; false when results are gated.

                          Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
                          surface:
                            * Closed poll → true (everyone)
                            * Author or business admin → true (always)
                            * Open + `result_visibility: hidden_until_close` → false
                            * Open + `result_visibility: live`, caller has voted → true
                            * Open + `result_visibility: live`, caller has not voted → false

                          When false, `total_votes` and each `options[].votes` /
                          `options[].percent` are returned as `null` so clients can render
                          a "results hidden" state without inferring whether anyone has
                          voted yet.
                      total_votes:
                        type: integer
                        minimum: 0
                        nullable: true
                        description: |
                          Aggregate count of distinct voters (sum of active PollVote rows).
                          Detail-only — returned on `GET /feeds/{id}` and on the
                          `/feeds/{id}/poll_votes` responses, omitted from list rows.
                          `null` when `results_visible` is false.
                      options:
                        type: array
                        description: |
                          Poll options. `id`, `text`, and `position` are returned on both
                          list rows and the detail endpoint. `votes` (per-option count)
                          and `percent` (0–100, rounded) are detail-only — list rows omit
                          them. Both are `null` when `results_visible` is false.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            text:
                              type: string
                            position:
                              type: integer
                            votes:
                              type: integer
                              nullable: true
                              description: Detail endpoint only. `null` when results
                                are gated.
                            percent:
                              type: integer
                              minimum: 0
                              maximum: 100
                              nullable: true
                              description: |
                                Detail endpoint only. Share of `total_votes` cast for this
                                option, rounded to the nearest integer (0–100). `null` when
                                results are gated. Matches the value rendered by the web
                                `_poll` partial so mobile and web stay in lockstep.
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: Not a poll, poll closed, change-vote disabled, no previous
            vote, or invalid option selection
  "/feeds/{id}/poll_option_voters":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List users who voted for a single poll option
      description: |
        Drill-down roster for one poll option — the users who picked it.
        A single endpoint serves both poll shapes:

          * `single` / `multi` voting modes → a flat `users` list.
          * `ranked` voting mode → `grouped` buckets, one per rank position
            the voters gave this option (rank 1 first), so a client can
            render the per-rank accordion.

        Each row carries the canonical v1 user shape (id / name / avatar_url)
        plus the row subtitle (job title / office location) — the same shape
        as `GET /feeds/{id}/viewers`. The voter set and per-voter rank are
        read at request time from the active poll votes.

        Authorization layers on top of the feed audience gate:
          * Anonymous polls (`is_anonymous = true`) never expose voters (403).
          * Otherwise the same visibility decider as the embedded poll block
            applies — an open `live` poll the caller has not voted in, or an
            open `hidden_until_close` poll viewed by a non-author/admin,
            returns 403.

        The roster is capped at 200 voters per option.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: option_id
        in: query
        required: true
        description: Poll option whose voters to list. Must belong to this feed's
          poll.
        schema:
          type: integer
      responses:
        '200':
          description: |
            Voter roster for the option. `users` is present for single/multi
            polls; `grouped` is present for ranked polls.
          content:
            application/json:
              schema:
                type: object
                required:
                - poll_option_id
                - voting_mode
                - total
                properties:
                  poll_option_id:
                    type: integer
                  voting_mode:
                    type: string
                    enum:
                    - single
                    - multi
                    - ranked
                  total:
                    type: integer
                    description: Number of voters returned (capped at 200).
                  users:
                    type: array
                    description: Flat voter list — present for single/multi polls.
                    items:
                      type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Voter's job title (row subtitle).
                        location:
                          type: string
                          nullable: true
                          description: Voter's office location (row subtitle).
                  grouped:
                    type: array
                    description: Per-rank buckets (rank 1 first) — present for ranked
                      polls.
                    items:
                      type: object
                      required:
                      - rank
                      - count
                      - users
                      properties:
                        rank:
                          type: integer
                          description: 1-based rank these voters gave the option.
                        count:
                          type: integer
                        users:
                          type: array
                          items:
                            type: object
                            required:
                            - id
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                                nullable: true
                              avatar_url:
                                type: string
                                nullable: true
                              title:
                                type: string
                                nullable: true
                              location:
                                type: string
                                nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Anonymous poll
          or results not yet visible to the caller:
        '404':
          description: Feed or poll option not found
        '422':
          description: Feed is not a poll
  "/news-feed/audiences":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List composer audiences (mobile)
      description: |
        Returns the audiences the caller may broadcast to from the composer.
        The list always begins with the virtual "My Direct Reports" segment,
        followed by every `NotificationRecipientGroup` the caller is allowed
        to target under the active rule:

          * Admin / News Feed app admin → every manageable group
          * Mode B (app-access rules configured) → groups in rules ∩ caller's groups
          * Mode A (no rules)                    → caller's groups only

        Each entry carries an approximate `member_count` so the picker can
        render "Engineering (42)". `member_count` is `null` when the group
        needs runtime context (location, shift, radius) that the bare lookup
        can't supply — clients should hide the count in that case.
      responses:
        '200':
          description: Allowed audiences for the composer
          content:
            application/json:
              schema:
                type: object
                required:
                - audiences
                properties:
                  audiences:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - kind
                      properties:
                        id:
                          oneOf:
                          - type: string
                            example: direct_reports
                          - type: string
                            example: '17'
                          description: |
                            `'direct_reports'` for the virtual segment, otherwise
                            the stringified NotificationRecipientGroup id.
                        name:
                          type: string
                          example: Engineering
                        description:
                          type: string
                          nullable: true
                          example: All engineering staff in the org
                          description: |
                            Human-readable description of the audience.
                            `null` for groups that don't have one configured.
                            The virtual `direct_reports` segment carries a
                            fixed sentence describing the relationship.
                        kind:
                          type: string
                          enum:
                          - virtual
                          - group
                        member_count:
                          type: integer
                          nullable: true
                          description: Approximate count; `null` when the group needs
                            runtime context.
        '401':
          description: Unauthorized
  "/news-feed/topics":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List topic taxonomy
      description: |
        Returns the News Feed topic taxonomy for the caller's business.
        Used by the feed filter chip strip and the composer topic picker.
        Names are normalized to lowercase-hyphen slug form.
      parameters:
      - name: managed
        in: query
        description: When true, restrict to admin-curated topics.
        schema:
          type: boolean
      - name: status
        in: query
        description: Lifecycle filter. Defaults to `active`.
        schema:
          type: string
          enum:
          - active
          - disabled
          default: active
      - name: q
        in: query
        description: Prefix-match against the normalized name (e.g. `safe` matches
          `safety-update`).
        schema:
          type: string
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
      responses:
        '200':
          description: Topic list
          content:
            application/json:
              schema:
                type: object
                required:
                - topics
                - meta
                properties:
                  topics:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          example: benefits
                        status:
                          type: string
                          enum:
                          - active
                          - disabled
                        managed:
                          type: boolean
                  meta:
                    type: object
                    properties:
                      count:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Unauthorized
  "/news-feed/my-posts":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List the caller's own posts (My Posts)
      description: |
        The mobile "My Posts" screen — the calling user's OWN communications
        across BOTH stores (feed posts and broadcasts), grouped by lifecycle.
        Faithfully mirrors the web `Apps::NewsFeedController#my_posts`: same two
        stores, same lifecycle scopes, same `updated_at DESC` merge ordering,
        and the same acknowledgement-progress source.

        Filters (`filter` query param, mutually exclusive):
          * `scheduled` — queued to publish (`status=scheduled`), EXCLUDING
                          anything currently held at an approval gate.
          * `approval`  — items sitting at a pending approval gate (a pending
                          `CommsHub::ApprovalRequest`), whatever the item's own
                          status. Alias: `awaiting_approval`.
          * `sent`      — already out: feeds `published`/`expired`, broadcasts
                          `published`/`archived`. Alias: `published`.

        Each row is projected into one kind-agnostic card shape whether it is a
        feed or a broadcast (`kind`). Acknowledgement progress
        (`acknowledged_count` / `recipient_count` / `acknowledgement_percent`)
        is populated only on the `sent` filter and only for posts that gate on
        acknowledgement (must-read feeds and ack-required broadcasts); it is
        `null` on every other row — matching exactly where the web renders the
        ack bar.

        `meta.filter_counts` carries the per-filter totals (scheduled /
        approval / sent) so the client can render the tab badges without extra
        round-trips.
      parameters:
      - name: filter
        in: query
        description: |
          Lifecycle filter; defaults to `sent`, matching the web My Posts
          screen. Unrecognized values fall back to `sent`.
        schema:
          type: string
          enum:
          - scheduled
          - approval
          - awaiting_approval
          - sent
          - published
          default: sent
      - name: page
        in: query
        description: 1-indexed page number.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The caller's posts for the requested filter
          content:
            application/json:
              schema:
                type: object
                required:
                - posts
                - meta
                properties:
                  posts:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - kind
                      - type
                      - type_label
                      - status
                      - status_label
                      - must_read
                      - title
                      - author
                      properties:
                        id:
                          type: integer
                        kind:
                          type: string
                          enum:
                          - feed
                          - broadcast
                        type:
                          type: string
                          enum:
                          - update
                          - question
                          - poll
                          - broadcast
                          description: Machine value — feed `content_type`, or `broadcast`.
                        type_label:
                          type: string
                          example: Update
                          description: Display label for the type chip (Update / Question
                            / Poll / Broadcast).
                        status:
                          type: string
                          enum:
                          - draft
                          - scheduled
                          - published
                          - expired
                          - archived
                          description: The item's own persisted status.
                        status_label:
                          type: string
                          example: Awaiting approval
                          description: |
                            Chip label — the humanized status, or
                            `Awaiting approval` on the `approval` filter.
                        must_read:
                          type: boolean
                          description: True for must-read feeds (broadcasts are always
                            false).
                        is_critical:
                          type: boolean
                          description: True for critical broadcasts (feeds are always
                            false).
                        requires_acknowledgement:
                          type: boolean
                          description: Whether the post gates on acknowledgement.
                        title:
                          type: string
                          description: Broadcast title, or the feed's derived headline.
                        summary:
                          type: string
                          nullable: true
                          description: Stripped, ≤220-char one-liner shown on the
                            card. `null` when the body is empty.
                        body:
                          type: string
                          nullable: true
                          description: Raw post body / broadcast description.
                        scheduled_at:
                          type: string
                          format: date-time
                          nullable: true
                        published_at:
                          type: string
                          format: date-time
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        acknowledged_count:
                          type: integer
                          nullable: true
                          description: |
                            Distinct acknowledgers. Populated only on `filter=sent`
                            for ack-gated posts; `null` otherwise.
                        recipient_count:
                          type: integer
                          nullable: true
                          description: Addressable audience size (the ack denominator).
                            `null` unless ack-gated on `sent`.
                        acknowledgement_percent:
                          type: integer
                          nullable: true
                          description: Rounded `acknowledged_count / recipient_count`
                            percentage. `null` unless ack-gated on `sent`.
                        topics:
                          type: array
                          description: Topic chips (feeds only, ≤3); empty for broadcasts.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                        author:
                          type: object
                          description: Always the calling user.
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            avatar_url:
                              type: string
                  meta:
                    type: object
                    required:
                    - filter
                    - page
                    - per_page
                    - total_pages
                    - total_count
                    - filter_counts
                    properties:
                      filter:
                        type: string
                        enum:
                        - scheduled
                        - approval
                        - sent
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total_pages:
                        type: integer
                      total_count:
                        type: integer
                      filter_counts:
                        type: object
                        description: Per-filter totals for the tab badges.
                        properties:
                          scheduled:
                            type: integer
                          approval:
                            type: integer
                          sent:
                            type: integer
        '401':
          description: Unauthorized
        '403':
          description: Communications not enabled or not accessible for this business
  "/news-feed/settings":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Effective News Feed configuration + caller capabilities
      description: |
        Returns the merged News Feed configuration (marketplace schema
        defaults ← business override ← admin flag override) plus a
        `capabilities` block derived from `NewsFeed::Permissions` and the
        `broadcast_channels` the caller may send a broadcast on. Mobile
        clients use this to decide which composer tabs to show, whether
        the must-read toggle is available, which delivery channels to
        draw, etc., without having to re-implement role rules.

        Read-only and callable by every authenticated user. The admin
        write surface lives at `/api/v1/admin/news-feed/settings`.
      responses:
        '200':
          description: Effective settings + caller capabilities
          content:
            application/json:
              schema:
                type: object
                required:
                - settings
                - capabilities
                - broadcast_channels
                properties:
                  settings:
                    type: object
                    additionalProperties: true
                    description: |
                      Free-form bag — keys come from the marketplace app's
                      configuration_schema. Common keys include
                      `composer_update_type`, `composer_question_type`,
                      `composer_poll_type`, `audience_post_to_everyone`,
                      `must_read_permission`.
                  capabilities:
                    type: object
                    properties:
                      can_compose_any:
                        type: boolean
                      can_post_update:
                        type: boolean
                      can_post_question:
                        type: boolean
                      can_post_poll:
                        type: boolean
                      can_post_must_read:
                        type: boolean
                      can_post_to_everyone:
                        type: boolean
                      can_post_announcement:
                        type: boolean
                        description: |
                          Whether the caller may send the Announcement kind of
                          the unified Communications composer. Rides the same
                          communicator grant as Must-read (the two differ in
                          what the READER owes, not in who may send), so it
                          tracks `can_post_must_read`.
                      can_post_broadcast:
                        type: boolean
                        description: |
                          Whether the caller may send the Broadcast kind. A
                          DIFFERENT grant from the feed kinds: the broadcast
                          engine must be entitled for the tenant (the
                          `broadcast` OR `communications` app) AND the caller
                          must hold `create` on broadcasts. This is what lets
                          the compose menu express "may announce but may not
                          broadcast" — deriving it from `can_post_must_read`
                          offers a send the server refuses.
                  broadcast_channels:
                    type: array
                    description: |
                      The channels a broadcast can be sent on in this tenant —
                      the Channels card of the web composer's Break-through
                      panel, answered as data so a client draws the same
                      controls instead of hardcoding a set that can disagree
                      with the tenant. Listed in composer order.

                      Same conditions as that page:

                      * `in_app` is always on and has no toggle
                        (`selectable: false`, no `param`).
                      * `email` / `sms` / `voice` post back inside
                        `broadcast[channels][]` on `POST /api/v1/broadcasts`.
                      * `signage` ("Break-room screens") is listed ONLY when
                        Digital Signage is entitled for the tenant AND at least
                        one active screen is registered — an entitled app with
                        no live screen is a control that cannot deliver, so the
                        entry is absent rather than present-and-useless.

                      Always present, for every caller — this describes the
                      TENANT, not the caller. WHO may broadcast is the separate
                      `capabilities.can_post_broadcast` flag: gate the compose
                      entry point on that, then use this list to draw the
                      controls. The array is never empty (`in_app` always
                      delivers).
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - selectable
                      - default_selected
                      properties:
                        key:
                          type: string
                          enum:
                          - in_app
                          - email
                          - sms
                          - voice
                          - signage
                        label:
                          type: string
                          description: Composer wording, e.g. `Voice call`, `Break-room
                            screens`.
                        selectable:
                          type: boolean
                          description: |
                            `false` only for `in_app`, which always delivers and
                            cannot be switched off.
                        default_selected:
                          type: boolean
                          description: |
                            The composer's pre-ticked state (Email on, SMS and
                            Voice off). A COMPOSER default, not a server one:
                            `POST /broadcasts` reads an ABSENT `channels` key as
                            "the author didn't touch channels" and leaves every
                            channel on, so a client rendering these controls
                            must send the full array it ends up with.
                        param:
                          type: string
                          nullable: true
                          description: |
                            Where this channel's value goes in the
                            `POST /api/v1/broadcasts` body: `channels[]` for the
                            per-user delivery channels, `publish_to_signage` for
                            break-room screens (location-bound, so deliberately
                            NOT folded into `channels[]`). `null` for `in_app`,
                            which has nothing to send.
                        hint:
                          type: string
                          nullable: true
                          description: The composer's tooltip for this channel, when
                            it has one.
                        location_ids_param:
                          type: string
                          description: |
                            `signage` only — `signage_location_ids[]`, the
                            optional site narrowing. Omit or send empty to show
                            on every screen.
                        screen_count:
                          type: integer
                          description: "`signage` only — active screens registered
                            for the tenant."
                        delivery_note:
                          type: string
                          description: |
                            `signage` only. Screens PULL: each shows the post at
                            its next refresh, and only where its own signage
                            admin has Communications posts switched on.
                        locations:
                          type: array
                          description: |
                            `signage` only — the sites that actually have an
                            active screen (sites without one are omitted, since
                            picking them would change nothing).
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
        '401':
          description: Unauthorized
  "/news-feed/policies":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Policies a Must-Read can ask readers to accept (composer picker)
      description: |
        The options behind the composer's "Linked policy" dropdown. Send the
        chosen `id` back as `feed[policy_id]` on `POST /feeds`; the feed
        payload then returns it — with the reader's acceptance state — in the
        `policy` block.

        Only PUBLISHED policies are listed (a draft cannot be accepted), and
        the list is empty unless the tenant is entitled to Policy Hub, which is
        where reading and accepting happen. Same list, same rules as the web
        composer's picker (both go through `Comms::LinkablePolicies`).

        A lean row per option — one policy's full detail (type, version,
        acceptance state, e-signature requirement) is
        `GET /news-feed/policies/{id}`.

        Composer-gated: a caller who can neither compose nor post a must-read
        gets 403.
      parameters:
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Narrows by title (the picker's type-ahead).
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: Linkable policies
          content:
            application/json:
              schema:
                type: object
                required:
                - policies
                - meta
                properties:
                  policies:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        requires_acknowledgment:
                          type: boolean
                          description: |
                            Whether accepting is even asked for. A policy that
                            requires no acknowledgment is still linkable (it
                            names what the must-read is about), but the card
                            should not promise an accept action for it.
                        url:
                          type: string
                          nullable: true
                          description: |
                            Absolute URL of the mobile Policy Hub screen — the
                            one surface that both reads and acknowledges. Null
                            when Policy Hub is not reachable by THIS caller, so
                            a client never renders a link that only bounces.
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
              example:
                policies:
                - id: 12
                  title: Heat Safety Procedure v4
                  requires_acknowledgment: true
                  url: https://acme.workforce.mangoapps.com/m/apps/policy-hub/policies/12
                meta:
                  total_count: 1
                  total_pages: 1
                  current_page: 1
                  per_page: 25
        '401':
          description: Unauthorized
        '403':
          description: Composing isn't enabled for this account
  "/news-feed/policies/{id}":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Details of one policy a Must-Read can ask readers to accept
      description: |
        Resolves a policy id the client already holds — from the `policy` block
        on a feed payload, from a saved draft, or from
        `GET /policy_hub/policies` — into the fields a composer's "Linked
        policy" field and a Must-Read card need to render. Send the `id` back as
        `feed[policy_id]` on `POST /feeds`; the feed payload then returns it,
        with the reader's acceptance state, in its `policy` block.

        Not a catalogue: enumerating and searching policies is
        `GET /policy_hub/policies`. Not the accept action either — the URL that
        accepts a policy in place is `acknowledge_url` on the `policy` block of
        a feed payload, which is where a Must-Read card gets it.

        404 for an id outside this business, for a DRAFT (never linkable — a
        draft cannot be accepted), and for every id when the tenant is not
        entitled to Policy Hub, which is where reading and accepting happen
        (the same entitlement rule the web composer's policy field applies —
        both go through `Comms::LinkablePolicies`). An ARCHIVED or RETIRED
        policy IS returned: a Must-Read published while it was live still names
        it, and its title is needed to render that card — `status` says it is no
        longer current and `url` goes null.

        Composer-gated: a caller who can neither compose nor post a must-read
        gets 403.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Policy detail
          content:
            application/json:
              schema:
                type: object
                required:
                - policy
                properties:
                  policy:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      description:
                        type: string
                        nullable: true
                      policy_type:
                        type: string
                      category:
                        type: string
                        nullable: true
                      status:
                        type: string
                        enum:
                        - published
                        - archived
                        - retired
                        description: |
                          `draft` is never returned (it 404s). A non-`published`
                          value means the policy is no longer current: keep
                          rendering the title on an existing Must-Read, but do
                          not offer it for a new one.
                      version_number:
                        type: integer
                      requires_acknowledgment:
                        type: boolean
                        description: |
                          Whether accepting is even asked for. A policy that
                          requires no acknowledgment is still linkable (it names
                          what the must-read is about), but the card should not
                          promise an accept action for it.
                      requires_esignature:
                        type: boolean
                        description: |
                          E-signature policies cannot be click-accepted from a
                          native client — the signing flow lives on the web app,
                          and `POST /policy_hub/policies/{id}/acknowledge`
                          refuses them.
                      published_at:
                        type: string
                        format: date-time
                        nullable: true
                      updated_at:
                        type: string
                        format: date-time
                      accepted:
                        type: boolean
                        description: |
                          Whether THIS caller has accepted the CURRENT version.
                          False for a caller whose acceptance HR has flagged for
                          re-acknowledgment, even though the underlying record
                          stays acknowledged — those are exactly the people
                          Policy Hub is chasing to re-accept.
                      accepted_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Null unless `accepted` is true.
                      url:
                        type: string
                        nullable: true
                        description: |
                          Absolute URL of the mobile Policy Hub screen — the one
                          surface that both reads and acknowledges. Null unless
                          the policy is still published AND Policy Hub is
                          reachable by THIS caller, so a client never renders a
                          link that only bounces.
              example:
                policy:
                  id: 12
                  title: Heat Safety Procedure v4
                  description: What to do above 95°F.
                  policy_type: safety
                  category: Safety
                  status: published
                  version_number: 4
                  requires_acknowledgment: true
                  requires_esignature: false
                  published_at: '2026-08-01T14:02:00Z'
                  updated_at: '2026-08-06T09:15:00Z'
                  accepted: false
                  accepted_at:
                  url: https://acme.workforce.mangoapps.com/m/apps/policy-hub/policies/12
        '401':
          description: Unauthorized
        '403':
          description: Composing isn't enabled for this account
        '404':
          description: No such policy in this business (or a draft
          or Policy Hub not entitled):
  "/media":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Upload a News Feed attachment ahead of the post (orphan media)
      description: |
        Mobile/API composer flow. Returns a presigned S3 PUT URL plus a
        FeedMedia row in "orphan" state (`feed_id` is null). The client
        then PUTs the bytes directly to S3 and finalizes via
        `POST /media/{id}/complete`. Finally the post is created with
        `POST /feeds` and `feed_media_ids: [<id>, ...]` — which atomically
        claims the orphans and attaches them to the new feed.

        Validation mirrors the nested per-feed endpoint: 10 in-flight
        attachments per user, per-type size caps (100MB image/gif,
        500MB video, 50MB file), MIME and extension blocklists, GIF /
        media admin flags.

        Orphan rows that are never claimed are swept after 24h by
        `NewsFeed::PurgeOrphanFeedMediaJob`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - media_type
              - mime_type
              - original_filename
              - file_size_bytes
              properties:
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                mime_type:
                  type: string
                original_filename:
                  type: string
                file_size_bytes:
                  type: integer
                  minimum: 1
                alt_text:
                  type: string
                width_px:
                  type: integer
                  description: Image/gif natural width — skips server-side Vips decode
                    if supplied.
                height_px:
                  type: integer
      responses:
        '201':
          description: Orphan media row created; client should PUT bytes to upload_url,
            then POST /media/{id}/complete.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    type: object
                    description: Serialized FeedMedia.
                  upload_url:
                    type: string
                    description: Presigned S3 PUT URL.
                  storage_key:
                    type: string
                  expires_in:
                    type: integer
                    description: Presigned URL TTL in seconds.
        '401':
          description: Unauthorized
        '422':
          description: Validation failed (mime/size/cap/admin-flag).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/media/{id}/complete":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Finalize an orphan media upload after the S3 PUT
      description: |
        Marks the orphan FeedMedia row as ready. For images/gifs, kicks
        off dimension extraction if `width_px`/`height_px` weren't sent on
        create. For videos, enqueues the MediaConvert transcode job and
        (optionally) the auto-subtitle pipeline. For files, flips status
        to `ready` immediately.

        Only the uploader can finalize their own orphan rows.
      parameters:
      - in: path
        name: id
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Updated media row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    type: object
        '401':
          description: Unauthorized
        '404':
          description: Media not found (already claimed, deleted, or owned by another
            user).
  "/inspections/inspections":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspections (personal or team)
      description: |
        Returns a paginated list of inspections, scoped role-aware:

        - **Default** (no `team` param or caller is not a manager): inspections
          the caller is the inspector for. Mirrors the desktop
          `/apps/inspections/inspections/my_inspections` action.
        - **`?team=true`** AND caller is `manager_or_above?` or app-admin for
          `inspections`: the team feed honoring the tenant's
          `team_inspections_scope` setting (location / department / none) via
          `Inspections::TeamScopeService`.

        A caller who passes `?team=true` but lacks manager / inspections-admin
        permission receives **403 Forbidden** — the request is rejected rather
        than silently downgraded to the personal feed, so clients learn they
        lack team visibility.

        **Ordering** depends on `status_type` (each value mirrors the
        corresponding desktop bucket action so the API and the web list agree):
        - `status_type=overdue` — earliest `due_at` first (most overdue at the
          top). Returns scheduled/in_progress inspections whose `due_at` is in
          the past.
        - `status_type=active` — **urgency order** on the personal feed:
          Overdue first, then In Progress, then Scheduled; within each group the
          soonest `due_at` first (NULLs last), then `created_at DESC` and `id
          DESC` as tiebreaks — a total order, so paging is stable and a row
          cannot repeat on one page and be skipped on the next. Returns
          inspections with `status IN (scheduled,
          in_progress)` — the rows counted by the personal-feed Active tab badge
          (`segment_counts.active` in this same response). Excludes `draft`
          (training-gated and not yet startable — reachable via the unfiltered
          list), `in_review` and `cancelled`. On the team feed (`?team=true`)
          `active` is not a tab and sorts by newest `created_at` first.
        - `status_type=in_progress` — most recently started first
          (`started_at DESC`, NULLs last).
        - `status_type=scheduled` — earliest scheduled first
          (`scheduled_at ASC`, NULLs last).
        - `status_type=in_review` — earliest `due_at` first (NULLs last).
        - `status_type=completed` — most recently completed first
          (`completed_at DESC`, NULLs last). Superset — includes passed,
          failed, and outcome-less rows.
        - `status_type=failed` — most recently completed first
          (`completed_at DESC`, NULLs last). Returns completed inspections
          that did not pass (`passed IS NOT TRUE` — i.e. `passed = false`
          OR `passed IS NULL`, matching the UI's "Failed" badge).
        - `status_type=passed` — most recently completed first
          (`completed_at DESC`, NULLs last). Returns completed inspections
          with `passed = TRUE` (strict — excludes outcome-less rows).
        - No `status_type` filter — ordering depends on the feed:
          - **Personal feed** (`team` omitted / `false`): the same **urgency
            order** as `status_type=active`, because the personal feed is a work
            queue and the most urgent work must land on page 1 rather than
            wherever `created_at` happens to put it. Every status is still
            returned (no filter is applied); `draft`, `in_review`, `completed`
            and `cancelled` simply sort after all actionable rows. Clients that
            narrow this list themselves therefore get overdue work first without
            having to send `status_type` or re-sort the page.
          - **Team feed** (`?team=true`): newest `created_at` first, matching
            the desktop "All" inspections page.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` to request the team feed. Requires manager-or-above
          or inspections app-admin permission; unauthorized callers get a
          403. Omit or set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: status_type
        in: query
        description: |
          Semantic status bucket. Also drives result ordering (see endpoint
          description). Omit for all statuses.
          `overdue`     = scheduled/in_progress with due_at < now.
          `active`      = status IN (scheduled, in_progress) — matches the
                          personal-feed Active tab badge
                          (`segment_counts.active`). Excludes `draft`
                          (training-gated, not yet startable).
          `in_progress` = status=in_progress.
          `scheduled`   = status=scheduled.
          `in_review`   = status=in_review.
          `completed`   = status=completed (includes passed + failed +
                          outcome-less).
          `failed`      = status=completed AND `passed IS NOT TRUE`
                          (matches the UI's "Failed" badge).
          `passed`      = status=completed AND `passed = TRUE` (strict).
        schema:
          type: string
          enum:
          - overdue
          - active
          - in_progress
          - scheduled
          - in_review
          - completed
          - failed
          - passed
      - name: type
        in: query
        description: Filter by inspection template category (case-insensitive).
        schema:
          type: string
      - name: template_id
        in: query
        description: Filter by exact inspection_template_id.
        schema:
          type: integer
      - name: location_id
        in: query
        description: |
          Team feed only. Restrict the team feed to a single location. Honored
          only when `team=true`, the tenant's `team_inspections_scope` is
          `location`, and the id is one the caller may filter by (see the
          `location_filter.options` block in the response — ids outside that
          set are ignored and the default "all my locations" scope is returned).
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of inspections (ordering depends on `status_type` — see
            description)
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSummary"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
                  location_filter:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionLocationFilter"
                    description: |
                      Present only for the team feed (`team=true`). The
                      permission-scoped set of locations the caller may filter
                      by, for the location dropdown. Absent on the personal feed.
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: Forbidden — Inspections app disabled, no access, or `team=true`
            without manager/admin permission
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Create an inspection
      description: |
        Creates a new inspection in `in_progress` state, with the caller as
        the inspector. Optionally accepts a batch of item updates and a
        `complete: true` flag for one-shot offline-drafted submissions —
        the inspection transitions to `completed` (or `in_review` if the
        template has an approval workflow) before responding.

        Pass an `Idempotency-Key` header so a retried POST returns the
        original response instead of creating a duplicate inspection.
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Client-generated UUID; retries with the same key return the original
          response.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCreateRequest"
      responses:
        '201':
          description: Inspection created (and optionally submitted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '404':
          description: Template not found or inactive
        '422':
          description: Validation failed
  "/inspections/inspections/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Inspection ID
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single inspection
      description: |
        Returns the full inspection payload — every item with its compliance
        status, attached `media_items` (photos + videos, polymorphic
        `MediaItem` rows), GPS, approval state, corrective actions, and
        metadata. (Replaces the legacy `photos: []` / `videos: []` keys;
        items now carry `media_items: [...]`, `photo_count`, `video_count`,
        and `media_count`.)
      responses:
        '200':
          description: Inspection detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '404':
          description: Inspection not found
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Update an inspection
      description: |
        Mirrors the desktop Edit Inspection form
        (`/apps/inspections/inspections/:id/edit`). The body's allowlist is the
        same set the web form exposes — `title`, `inspection_template_id`,
        `location_id`, `scheduled_at`, `due_at`, and `notes`. Fields outside
        the allowlist are silently dropped.

        **Template-change guard** (web parity): the desktop form disables the
        template select once the inspection is `in_progress` or `completed`.
        This endpoint enforces the same rule — submitting a different
        `inspection_template_id` in those states returns
        `422 template_locked`. Other states (draft, scheduled, in_review,
        cancelled) leave the field editable.

        Authority: the assigned inspector OR a manager / inspections-admin.
        Members cannot edit colleagues' inspections (403).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionUpdateRequest"
      responses:
        '200':
          description: Inspection updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller lacks modify permission
        '404':
          description: Inspection not found
        '422':
          description: Validation failed, invalid_template, invalid_location, or template_locked
    delete:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Delete an inspection
      description: |
        Removes the inspection (mirrors the desktop dropdown's Delete action).
        Authority matches the web: the assigned inspector OR a manager /
        inspections-admin. Members cannot delete colleagues' inspections.
      responses:
        '204':
          description: Inspection deleted
        '403':
          description: Forbidden — caller lacks modify permission
        '404':
          description: Inspection not found
        '422':
          description: Inspection could not be deleted (callback / FK constraint)
  "/inspections/inspections/{id}/start":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Transition draft/scheduled → in_progress
      description: |
        Only the assigned inspector can start an inspection. No-op if the
        inspection is already `in_progress` (returns 200 with the current
        state).
      responses:
        '200':
          description: Inspection started
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller is not the inspector
        '422':
          description: Inspection is not in a startable state
  "/inspections/inspections/{id}/complete":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Submit an inspection for review or final completion
      description: |
        Transitions the inspection to `completed` (templates without an
        approval workflow) or `in_review` (templates with an approval
        workflow). Accepts an optional base64-encoded PNG signature and
        inspector notes, plus an optional `corrective_actions` array to
        create or update follow-up actions atomically as part of the same
        completion (rolled back together with the state transition if any
        entry fails validation).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCompleteRequest"
      responses:
        '200':
          description: Inspection completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller is not the inspector
        '422':
          description: |
            Inspection is not in a completable state, or `items_pending` —
            one or more items that block completion are still unanswered.
            Conditionally-hidden items (unmet `show_when`) and visible items
            whose `require_when` isn't currently satisfied are excluded from
            this gate; a visible item whose `require_when` IS satisfied must
            be answered. Details carry `pending_count`.
  "/inspections/inspections/{id}/cancel":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Cancel an in-flight inspection
      description: |
        Marks the inspection as `cancelled`. Only the assigned inspector
        (or a manager) can cancel. A reason is recommended for the audit
        log but not enforced at the API layer.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCancelRequest"
      responses:
        '200':
          description: Inspection cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller cannot cancel this inspection
        '422':
          description: Inspection is already completed
  "/inspections/inspections/{id}/sync":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Batched draft save / offline sync for an in-flight inspection
      description: |
        Applies a batch of item updates + inspection-level notes/GPS to an
        in-progress inspection without transitioning state. Rejects writes
        when the inspection is completed, cancelled, or in_review (409).
        Pass `complete: true` to optionally call `submit!` after applying
        updates. Used by the native client's Save button (draft persistence)
        and by the offline-sync flow when buffered edits are flushed.

        Also accepts a `corrective_actions` array to create / update
        follow-up actions atomically alongside the item writes — invalid
        entries (cross-tenant `assigned_to_id`, off-inspection
        `inspection_item_id`, bad priority / status) roll back the entire
        sync (422) rather than producing partial writes.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionSyncRequest"
      responses:
        '200':
          description: Inspection updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller is not the inspector
        '409':
          description: Inspection is locked (completed / cancelled / in_review)
        '422':
          description: |
            Validation failure on one of the items, or (with `complete: true`)
            `items_pending` — see the complete endpoint; conditionally-hidden
            and not-currently-required items don't block.
  "/inspections/templates":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspection templates
      description: |
        Returns paginated inspection templates available to the caller's
        business. By default only active templates are returned; include
        system-wide templates (business_id IS NULL) with `include_system=true`.
      parameters:
      - name: active_only
        in: query
        schema:
          type: boolean
          default: true
        description: Restrict to active templates.
      - name: category
        in: query
        schema:
          type: string
        description: Exact category match (e.g. `safety`, `quality`, `equipment`).
      - name: q
        in: query
        schema:
          type: string
        description: Case-insensitive search across name and description.
      - name: include_system
        in: query
        schema:
          type: boolean
          default: false
        description: Include system templates (business_id IS NULL).
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Templates list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionTemplateSummary"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
  "/inspections/templates/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a template with full item definitions
      description: |
        Returns the full template payload — every item the inspector will
        be asked to answer, with item-type metadata, options, min/max
        bounds, and the failure-prompt settings the form renderer needs.
      responses:
        '200':
          description: Template detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    "$ref": "#/components/schemas/InspectionTemplateDetail"
        '404':
          description: Template not found
  "/inspections/corrective_actions":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List corrective actions
      description: |
        Returns paginated corrective actions. Default scope is "assigned to
        me"; managers can opt into the team scope via `?team=true`.

        The personal ("assigned to me") feed defaults to **open** actions
        (`pending` + `in_progress`) when no `status`/`overdue` filter is
        given — mirroring the desktop My Actions list. This keeps a completed
        action off the Active tab once it has been marked complete. Pass an
        explicit `status` (including `status=completed` for the Completed tab,
        or `status=all` for every status) to opt out of that default. The team
        scope is never narrowed this way.
      parameters:
      - name: assigned_to_me
        in: query
        schema:
          type: boolean
          default: true
        description: |
          When false, also surfaces actions the caller created (non-manager
          fallback). Ignored when `team=true` resolves to manager scope.
      - name: for_inspection_id
        in: query
        schema:
          type: integer
        description: Restrict to actions on a specific inspection.
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          - open
          - closed
          - all
        description: |
          Raw enum (`pending`/`in_progress`/`completed`/`cancelled`) or a
          semantic segment: `open` (pending + in_progress — the Active tab),
          `closed` (completed + cancelled), or `all` (every status, opts out
          of the personal open-by-default).
      - name: overdue
        in: query
        schema:
          type: boolean
        description: Only open actions past their due_date.
      - name: team
        in: query
        schema:
          type: boolean
          default: false
        description: |
          Manager-only. Widens the scope across the team. Non-managers
          who pass `team=true` silently get the personal scope.
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Corrective actions list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionCorrectiveAction"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
  "/inspections/corrective_actions/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single corrective action
      responses:
        '200':
          description: Corrective action detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '404':
          description: Not found
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Update a corrective action
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCorrectiveActionUpdateRequest"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '403':
          description: Forbidden
        '404':
          description: Not found
        '422':
          description: Validation failed
  "/inspections/corrective_actions/{id}/complete":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Mark a corrective action complete
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolution_notes:
                  type: string
                  nullable: true
      responses:
        '200':
          description: Completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '403':
          description: Forbidden
        '404':
          description: Not found
        '422':
          description: Already closed
  "/inspections/direct_uploads":
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Request a signed direct-upload URL for an inspection photo or video
      description: |
        Wraps Active Storage's direct-upload endpoint with API-style auth
        and JSON envelope. The client SHA256-hashes its bytes, calls this
        endpoint to get back a presigned S3 URL plus the required headers,
        PUTs the raw bytes directly to S3, then passes the returned
        `signed_id` back to `POST inspections/:id/items/:id/media` (or the
        `inspections#create` / `inspections#sync` `media_uploads` array)
        to attach the blob.

        Limits: photos ≤ 20 MB, videos ≤ 50 MB. Allowed content types are
        listed in `DirectUploadBlobRequest.content_type`. To upload a
        non-media file (e.g. a PDF or document), set the top-level `non_media`
        flag to true — that bypasses the content-type allow-list (the size cap
        still applies, ≤ 50 MB for non-photo types).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/DirectUploadBlobRequest"
      responses:
        '201':
          description: Signed upload URL issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  blob:
                    "$ref": "#/components/schemas/DirectUploadBlobResponse"
        '422':
          description: Invalid content_type or file too large
  "/inspections/inspections/awaiting_my_review":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Inspections in_review that the caller can approve
      description: |
        Returns inspections currently in `in_review` where the calling user
        is eligible to approve or reject at the current approval level.
        Backed by `ApprovalRequest.pending` filtered by
        `current_approval_level.can_approve?`. Capped at 50 rows — managers
        with deeper queues should also call the team list with
        `?status=in_review`.
      responses:
        '200':
          description: Pending review items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSummary"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Inspections app disabled or no access
  "/inspections/inspections/{id}/bundle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: If-None-Match
      in: header
      required: false
      schema:
        type: string
      description: ETag from a prior bundle response; returns 304 if unchanged.
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline-fill bundle for a single in-flight inspection
      description: |
        Returns the inspection + its template (with all template items) +
        attached media references + the regulatory KB excerpts the form
        renderer needs to keep working after the network drops. Single
        round trip — the native client uses this on "Open inspection" so
        the inspector can continue offline. Honors `If-None-Match` for
        cheap revalidation.
      responses:
        '200':
          description: Bundle payload
          content:
            application/json:
              schema:
                type: object
                required:
                - inspection
                - template
                - etag
                - fetched_at
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  template:
                    "$ref": "#/components/schemas/InspectionTemplateDetail"
                  regulatory_kb:
                    description: Reserved. Currently ALWAYS an empty array, for every
                      tenant and every template — inspection templates carry no link
                      to the regulatory knowledge base, so there is nothing for the
                      server to resolve. The field is retained so the response shape
                      stays stable for existing clients; it can only become populated
                      once a template <-> knowledge base relationship exists. Do not
                      build a client feature that depends on it being non-empty.
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                          nullable: true
                        jurisdiction:
                          type: string
                          nullable: true
                        regulation:
                          type: string
                          nullable: true
                        summary:
                          type: string
                          nullable: true
                        updated_at:
                          type: string
                          format: date-time
                          nullable: true
                  etag:
                    type: string
                  fetched_at:
                    type: string
                    format: date-time
        '304':
          description: Not modified — matches client's If-None-Match
        '404':
          description: Inspection not found
  "/inspections/inspections/{id}/review/approve":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Approve an inspection at the current review level
      description: |
        Advances the in_review inspection's approval workflow by one level.
        The caller must satisfy `can_approve?` on the current
        `ApprovalLevel` (typically a manager or assigned reviewer).
        Once the final level approves, the inspection transitions to
        `completed`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  nullable: true
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  approval_request:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                      current_level:
                        type: integer
                        nullable: true
        '422':
          description: Inspection is not in review / caller not eligible at this level
  "/inspections/inspections/{id}/review/reject":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reject an inspection at the current review level
      description: |
        Sends the inspection back to the inspector (transitions to
        `in_progress`) with the reviewer's required comment captured on
        the `ApprovalRequest`. Comment is required — empty rejects 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comments
              properties:
                comments:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  approval_request:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
        '422':
          description: Comment missing / inspection not in review / caller not eligible
  "/inspections/inspections/{inspection_id}/items/{id}":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Update a single inspection item
      description: |
        Sets compliance status / response value / notes / failure severity
        on one inspection item. Only the assigned inspector (or a manager)
        can update. `compliance_status` and `failure_severity` are
        allowlisted on the server — invalid values 422 with a clear error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                compliance_status:
                  type: string
                  enum:
                  - pending
                  - passed
                  - failed
                  - na
                  - skipped
                response_value:
                  type: string
                  nullable: true
                notes:
                  type: string
                  nullable: true
                failure_severity:
                  type: string
                  enum:
                  - minor
                  - moderate
                  - severe
                  - critical
                  nullable: true
      responses:
        '200':
          description: Item updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/InspectionItem"
                  inspection_progress:
                    type: number
                    format: float
                    nullable: true
                  score_breakdown:
                    type: object
                    nullable: true
                    additionalProperties: true
        '403':
          description: Forbidden — caller cannot modify this inspection
        '404':
          description: Inspection or item not found
        '422':
          description: Invalid status / validation failed
  "/inspections/inspections/{inspection_id}/items/{id}/media":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Attach a photo or video to an inspection item
      description: |
        Single canonical media-upload endpoint backed by `MediaItem`.
        Two upload modes (same contract):
          1. Multipart `{ file: <upload>, kind: 'photo'|'video' }` — the
             API uploads through Rails in one request. Use when the client
             has the bytes available locally.
          2. Signed blob `{ blob_signed_id, kind }` — the client called
             `POST /inspections/direct_uploads` to PUT bytes to S3 and is
             now attaching the resulting blob.
        `kind` is optional when `content_type` starts with `image/` or
        `video/`; explicit otherwise.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                kind:
                  type: string
                  enum:
                  - photo
                  - video
                blob_signed_id:
                  type: string
          application/json:
            schema:
              type: object
              properties:
                blob_signed_id:
                  type: string
                kind:
                  type: string
                  enum:
                  - photo
                  - video
      responses:
        '201':
          description: Media attached
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_item:
                    type: object
                    properties:
                      id:
                        type: integer
                      media_kind:
                        type: string
                        enum:
                        - photo
                        - video
                      content_type:
                        type: string
                      byte_size:
                        type: integer
                      original_filename:
                        type: string
                        nullable: true
                      url:
                        type: string
                      thumb_url:
                        type: string
                        nullable: true
                      has_annotations:
                        type: boolean
                      captured_at:
                        type: string
                        format: date-time
                        nullable: true
                  media_count:
                    type: integer
                  item:
                    "$ref": "#/components/schemas/InspectionItem"
        '403':
          description: Forbidden
        '422':
          description: Invalid file / unsupported content type / file too large
  "/inspections/inspections/{inspection_id}/items/{id}/media/{media_item_id}":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: media_item_id
      in: path
      required: true
      schema:
        type: integer
    delete:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Soft-delete a MediaItem attached to an inspection item
      description: |
        Soft-deletes the `MediaItem` (`deleted_at` set, file kept on S3).
        Mirrors `MediaItemsController#destroy`: fires the subject hook so
        a photo-required item that auto-passed via
        `on_media_item_attached` flips back to `pending` when the last
        photo is removed.
      responses:
        '200':
          description: Media removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_count:
                    type: integer
        '404':
          description: Media item not found
  "/inspections/inspections/{inspection_id}/items/{id}/annotation":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Save annotation data for a MediaItem
      description: |
        Persists a JSON annotation payload (drawing strokes, arrows,
        labels) onto the named MediaItem. Matches the canonical
        `MediaItemsController` annotation contract — sets
        `annotation_data` + `has_annotations: true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - media_item_id
              - annotation_data
              properties:
                media_item_id:
                  type: integer
                annotation_data:
                  type: object
                  additionalProperties: true
      responses:
        '200':
          description: Annotation saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_item_id:
                    type: integer
                  annotation:
                    type: object
                    additionalProperties: true
        '404':
          description: Media item not found
        '422':
          description: media_item_id or annotation_data missing
  "/inspections/inspections/{inspection_id}/corrective_actions":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Create a corrective action nested under an inspection
      description: |
        Creates an `InspectionCorrectiveAction` linked to the parent
        inspection. `assigned_to_id` and `inspection_item_id` are
        cross-tenant validated — a user outside the business, or an item
        from another inspection, are rejected 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                corrective_action:
                  type: object
                  required:
                  - description
                  properties:
                    inspection_item_id:
                      type: integer
                      nullable: true
                    description:
                      type: string
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      default: medium
                    due_date:
                      type: string
                      format: date
                      nullable: true
                    assigned_to_id:
                      type: integer
                      nullable: true
                    status:
                      type: string
                      enum:
                      - pending
                      - in_progress
                      - completed
                      - cancelled
                      default: pending
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '404':
          description: Inspection not found
        '422':
          description: Invalid assignee / item / validation failed
  "/inspections/templates/{id}/bundle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: If-None-Match
      in: header
      required: false
      schema:
        type: string
      description: ETag from a prior bundle response; returns 304 if unchanged.
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline pre-fetch bundle for an inspection template
      description: |
        Returns the full template + items + regulatory KB. The native
        client uses this so an inspector can pick a template, drop the
        network, fill an inspection locally, and `POST .../inspections`
        with the buffered answers on reconnect.
      responses:
        '200':
          description: Template bundle
          content:
            application/json:
              schema:
                type: object
                required:
                - template
                - etag
                - fetched_at
                properties:
                  template:
                    "$ref": "#/components/schemas/InspectionTemplateDetail"
                  regulatory_kb:
                    description: Reserved. Currently ALWAYS an empty array, for every
                      tenant and every template — inspection templates carry no link
                      to the regulatory knowledge base, so there is nothing for the
                      server to resolve. The field is retained so the response shape
                      stays stable for existing clients; it can only become populated
                      once a template <-> knowledge base relationship exists. Do not
                      build a client feature that depends on it being non-empty.
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                          nullable: true
                        jurisdiction:
                          type: string
                          nullable: true
                        regulation:
                          type: string
                          nullable: true
                        summary:
                          type: string
                          nullable: true
                        updated_at:
                          type: string
                          format: date-time
                          nullable: true
                  etag:
                    type: string
                  fetched_at:
                    type: string
                    format: date-time
        '304':
          description: Not modified — matches client's If-None-Match
        '404':
          description: Template not found
  "/inspections/schedules":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspection schedules
      description: |
        Read-only list of recurring inspection schedules. Schedule CRUD
        stays on the desktop admin surface — the native client only
        browses ("what's due at my location"). `?mine=true` restricts to
        schedules where the caller is the assigned inspector.
      parameters:
      - name: active
        in: query
        schema:
          type: boolean
        description: Filter on active/inactive. Defaults to active when omitted.
      - name: frequency
        in: query
        schema:
          type: string
      - name: location_id
        in: query
        schema:
          type: integer
      - name: template_id
        in: query
        schema:
          type: integer
      - name: mine
        in: query
        schema:
          type: boolean
          default: false
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Schedules
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionScheduleSummary"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
  "/inspections/schedules/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single schedule
      responses:
        '200':
          description: Schedule detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  schedule:
                    "$ref": "#/components/schemas/InspectionScheduleSummary"
        '404':
          description: Schedule not found
  "/inspections/schedules/due_today":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Schedules ready to generate now ("due today" feed)
      description: |
        Capped at 50 rows. Inspector-scoped by default; managers can
        request the team view with `?team=true`. Read-only — generation
        itself runs on the server via the scheduled job.
      parameters:
      - name: team
        in: query
        schema:
          type: boolean
          default: false
        description: Manager-only widen to team scope.
      responses:
        '200':
          description: Due-today schedules
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionScheduleSummary"
  "/inspections/claimable":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List items the caller can claim
      description: |
        Cross-cycle feed of every item the caller can claim right now — the
        mobile "My Inspections → Available to claim" surface. A claimable item is
        a pending `InspectionCycleItem` (no inspection yet) on an active
        `per_item` cycle whose `assignment_strategy = claim_pool`, at a location
        the caller is assigned to. Location-bounded for everyone (no manager
        bypass in the feed — a manager can still claim out-of-location items via
        the claim endpoint, but won't see them here). Optional `cycle_id`
        narrows to a single cycle.
      parameters:
      - name: cycle_id
        in: query
        schema:
          type: integer
        description: Narrow the feed to a single cycle.
      - name: location_id
        in: query
        schema:
          type: integer
        description: 'Narrow the feed to a single location. The feed is already bounded
          to the caller''s assigned locations, so this only further filters within
          that set — an unassigned location returns no items.

          '
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Claimable items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionCycleItem"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
  "/inspections/cycles":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspection cycles
      description: |
        Read-only paginated list of cycles. Cycle creation / activation /
        completion stays on the desktop admin surface.
      parameters:
      - name: status
        in: query
        schema:
          type: string
        description: When omitted, defaults to active cycles only.
      - name: template_id
        in: query
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Cycles
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionCycleSummary"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
  "/inspections/cycles/active":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Active-cycle banner + the caller's coverage sweeps
      description: |
        The "My Inspections" home surface, mirroring the desktop
        `apps/inspections/inspections#my_inspections` page as two buckets:

        - `active_cycle` — the single most-recently-activated active,
          non-coverage_sweep cycle (the "Start {template}" banner). Not
          audience-filtered — matches the web banner. Carries `my_inspection`
          so the client shows Resume vs Start. Null when there is no active
          startable cycle.
        - `sweeps` — coverage sweeps the caller can work, via the same
          `InspectionSweep.for_inspector` rule the web uses (claim_pool → anyone
          at the location; zones → assigned / session inspectors).
      responses:
        '200':
          description: Active-cycle banner + sweeps
          content:
            application/json:
              schema:
                type: object
                properties:
                  active_cycle:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionCycleSummary"
                    nullable: true
                  sweeps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSweepSummary"
  "/inspections/cycles/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single cycle with progress
      responses:
        '200':
          description: Cycle detail with `progress` block
          content:
            application/json:
              schema:
                type: object
                properties:
                  cycle:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionCycleSummary"
                    - type: object
                      properties:
                        progress:
                          type: object
                          properties:
                            total:
                              type: integer
                            by_status:
                              type: object
                              additionalProperties:
                                type: integer
        '404':
          description: Cycle not found
  "/inspections/cycles/{id}/start_inspection":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Start (or resume) an inspection for this cycle
      description: |
        Mobile equivalent of the desktop "Start <template>" cycle-banner button.
        Resumes the caller's existing in-progress/draft inspection for this
        cycle if present (no duplicate); otherwise creates one linked to the
        cycle with the inspector's primary work location pre-attached. If the
        cycle's template requires training the caller hasn't completed, the
        inspection is created as a draft. Returns `201` when a new inspection
        is created and `200` when an existing one is resumed.
      responses:
        '200':
          description: Existing inspection resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  resumed:
                    type: boolean
                    example: true
        '201':
          description: New inspection created
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  resumed:
                    type: boolean
                    example: false
        '422':
          description: Cycle is not active or has no template configured
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Cycle not found
  "/inspections/cycles/{cycle_id}/items/{id}/claim":
    parameters:
    - name: cycle_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Claim one unassigned cycle item
      description: |
        Claim one item from the pool. Race-safe — a guarded UPDATE flips exactly
        one of N simultaneous claimers; the winner gets a freshly-created
        inspection and can route straight into the editable form. Returns the
        same `InspectionDetail` shape `start_inspection` returns. Eligibility
        mirrors the web: an inspections manager/admin (claims anywhere) or a
        member assigned to the item's location. `409 already_claimed` is
        non-retryable — show "Already claimed", refresh the list, do not open
        the form.
      responses:
        '201':
          description: Item claimed — inspection created
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: "`not_eligible` — caller is not assigned to the item's location"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: "`already_claimed` — another user won the race (non-retryable)"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`cycle_inactive` — the cycle is no longer active"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Cycle or item not found
  "/inspections/sweeps":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Sweeps the caller can work
      description: The coverage sweeps this inspector can work (InspectionSweep.for_inspector)
        — same rule as the web "Coverage sweeps" section.
      responses:
        '200':
          description: Sweeps
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSweepSummary"
  "/inspections/sweeps/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Full sweep detail (aisles + grid + findings)
      responses:
        '200':
          description: Sweep detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  sweep:
                    "$ref": "#/components/schemas/InspectionSweepDetail"
        '403':
          description: Not authorized for this sweep
        '404':
          description: Sweep not found
  "/inspections/sweeps/{id}/bundle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline warm-up bundle
      description: Full detail + log-issue form schema + components. Supports If-None-Match
        → 304.
      responses:
        '200':
          description: Bundle
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/InspectionSweepBundle"
        '304':
          description: Not modified (ETag matched)
  "/inspections/sweeps/{id}/claim_aisle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Claim the next unclaimed aisle (race-safe)
      description: Idempotency-Key supported — a retried claim returns the same aisle
        instead of grabbing a second.
      responses:
        '200':
          description: Aisle claimed
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
        '422':
          description: '`no_aisles` — nothing left to claim (the normal end-of-sweep
            outcome). `action_failed` — an unclassified server-side failure; treat
            it as retryable, NOT as "the sweep is finished".'
  "/inspections/sweeps/{id}/pause":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Pause / hand off
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                handoff_note:
                  type: string
                frontier_level:
                  type: integer
      responses:
        '200':
          description: Session ended
  "/inspections/sweeps/{id}/sync":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline batch flush
      description: Buffered cells + findings + resets + resume cursor in one request.
        Idempotency-Key dedupes a replayed batch; a signed-off sweep rejects writes
        (409).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                cells:
                  type: array
                  items:
                    type: object
                    properties:
                      aisle_id:
                        type: integer
                      bay:
                        type: integer
                      level:
                        type: integer
                exceptions:
                  type: array
                  items:
                    type: object
                  description: Each item mirrors the POST /exceptions body (component,
                    severity, bay, level, answers, and photos via photo_ids or blob_signed_ids).
                resets:
                  type: array
                  items:
                    type: object
                    properties:
                      aisle_id:
                        type: integer
                      bay:
                        type: integer
                frontier:
                  type: object
                  properties:
                    aisle_id:
                      type: integer
                    bay:
                      type: integer
      responses:
        '200':
          description: Applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  sweep:
                    "$ref": "#/components/schemas/InspectionSweepDetail"
                  applied:
                    type: object
                    properties:
                      cells:
                        type: integer
                      exceptions:
                        type: integer
                      resets:
                        type: integer
        '409':
          description: "`sweep_locked` — sweep is signed off"
  "/inspections/sweeps/{id}/sign_off":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Complete / sign off the sweep
      description: Routes for approval when the template requires it, else signs off.
        422 while a critical finding is open or already pending review.
      responses:
        '200':
          description: Signed off or sent for approval
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: signed_off | pending_approval
                  sweep:
                    "$ref": "#/components/schemas/InspectionSweepSummary"
        '422':
          description: "`unresolved_criticals` or `already_pending`"
  "/inspections/sweeps/{id}/review/approve":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Approve a pending review
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
      responses:
        '200':
          description: Approved (may sign off on final level)
        '422':
          description: "`cannot_review` — not awaiting review / not eligible"
  "/inspections/sweeps/{id}/review/reject":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reject a pending review (returns for rework)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required:
              - comments
              properties:
                comments:
                  type: string
      responses:
        '200':
          description: Returned for rework
        '422':
          description: "`cannot_review` — missing reason / not eligible"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/mark_passed":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Mark a shelf (bay, level) passed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bay
              - level
              properties:
                bay:
                  type: integer
                level:
                  type: integer
      responses:
        '200':
          description: Aisle (with updated grid)
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
        '422':
          description: "`out_of_range`"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/reset_bay":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reset one bay to pending (keeps logged findings)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bay
              properties:
                bay:
                  type: integer
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/reset_cell":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Un-mark one shelf (toggle a passed cell back to pending)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bay
              - level
              properties:
                bay:
                  type: integer
                level:
                  type: integer
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/reset_aisle":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reset the whole aisle (clears cells + soft-voids findings)
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/cover":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Bulk-cover the aisle (pass all non-exception shelves)
      responses:
        '200':
          description: Aisle covered
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
                  next_aisle_id:
                    type: integer
                    nullable: true
                  done:
                    type: boolean
  "/inspections/sweeps/{sweep_id}/aisles/{id}/skip":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Skip an aisle (obstruction / unreachable)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                skip_reason:
                  type: string
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/unskip":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Restore a skipped aisle
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/advance_frontier":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Manager manually sets the frontier
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - frontier_bay
              properties:
                frontier_bay:
                  type: integer
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/exceptions":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List findings
      parameters:
      - name: severity
        in: query
        schema:
          type: string
      - name: status
        in: query
        schema:
          type: string
      - name: aisle_id
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Findings
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSweepException"
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Log a finding
      description: 'Attach photos by EITHER blob_signed_ids (recommended: direct-upload
        bytes via /inspections/direct_uploads, then send the signed ids) OR photo_ids
        (pending inbox MediaItem ids). Idempotency-Key dedupes a replayed POST.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                bay:
                  type: string
                level:
                  type: string
                component:
                  type: string
                damage:
                  type: string
                severity:
                  type: string
                  description: 'case-insensitive: observation|minor|major|critical'
                note:
                  type: string
                answers:
                  type: object
                photo_ids:
                  type: array
                  items:
                    type: integer
                  description: Pending inbox MediaItem ids
                blob_signed_ids:
                  type: array
                  items:
                    type: string
                  description: Signed Active Storage blob ids from /inspections/direct_uploads
      responses:
        '201':
          description: Finding logged
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
                  corrective_action_created:
                    type: boolean
  "/inspections/sweeps/{sweep_id}/exceptions/{id}":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Finding detail
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
        '404':
          description: Not found
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Edit a logged finding
      description: Only provided fields change (omitted = unchanged; empty string
        clears). Status is preserved — use resolve/escalate to change it. Photos (photo_ids
        / blob_signed_ids) are additive. Moving bay/level/aisle repositions the red
        grid cell.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                bay:
                  type: string
                level:
                  type: string
                component:
                  type: string
                damage:
                  type: string
                severity:
                  type: string
                  description: 'case-insensitive: observation|minor|major|critical'
                note:
                  type: string
                answers:
                  type: object
                photo_ids:
                  type: array
                  items:
                    type: integer
                  description: Pending inbox MediaItem ids (additive)
                blob_signed_ids:
                  type: array
                  items:
                    type: string
                  description: Signed Active Storage blob ids (additive)
      responses:
        '200':
          description: Updated finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
        '422':
          description: Update failed
        '404':
          description: Not found
    delete:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Delete (soft-void) a logged finding
      description: Soft-voids the finding — clears its red grid cell and hides it
        from the finding lists (index, sweep detail, notable_exceptions), while preserving
        the audited Inspection row and any corrective action. Not reversible via the
        API.
      responses:
        '204':
          description: Deleted
        '404':
          description: Not found
        '422':
          description: Delete failed
  "/inspections/sweeps/{sweep_id}/exceptions/{id}/resolve":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Mark a finding resolved
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
  "/inspections/sweeps/{sweep_id}/exceptions/{id}/escalate":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Escalate a finding (opens a corrective action)
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
  "/inspections/bundles/sync_manifest":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: App-start warm-up bundle
      description: |
        One round-trip payload the native client fetches on app start
        (and after returning from offline). Pre-caches templates, the
        caller's open inspections, the caller's open corrective actions,
        and upcoming schedules — all sized so a single HTTP call covers
        a cold launch. Honors `If-None-Match` for cheap revalidation.
      parameters:
      - name: include
        in: query
        schema:
          type: string
        description: |
          Comma-separated subset of
          `templates,inspections,corrective_actions,schedules`. Defaults
          to all four.
      - name: days_ahead
        in: query
        schema:
          type: integer
          default: 7
          minimum: 1
          maximum: 60
        description: Schedules window in days from now.
      - name: If-None-Match
        in: header
        required: false
        schema:
          type: string
      responses:
        '200':
          description: Manifest payload
          content:
            application/json:
              schema:
                type: object
                required:
                - fetched_at
                - etag
                properties:
                  templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionTemplateSummary"
                  inspections:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSummary"
                  corrective_actions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionCorrectiveAction"
                  schedules:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionScheduleSummary"
                  fetched_at:
                    type: string
                    format: date-time
                  etag:
                    type: string
        '304':
          description: Not modified — matches client's If-None-Match
  "/messaging/threads":
    get:
      tags:
      - Messaging
      summary: List conversations (chat list)
      description: |
        Paginated list of the caller's direct-message conversations, newest
        activity first. Each row carries an unread count and a compact
        last-message preview (with an attachments flag).

        By default this returns the caller's **live** conversations. Pass
        `archived=true` to return the caller's **archived** conversations
        instead (the ones hidden from the live list once the caller archives
        them; a new message resurfaces a thread back to the live list). Archive
        is per-caller, so the two slices differ per user on shared group threads.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number (1-indexed)
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: archived
        in: query
        required: false
        description: When true, returns the caller's archived conversations instead
          of the live list. Accepts true/false/1/0; defaults to false (live list).
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Paginated conversation list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingThread"
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/MessagingPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope
    post:
      tags:
      - Messaging
      summary: Start a conversation (and optionally post the first message)
      description: |
        Creates a 1:1 conversation (one recipient — idempotent, returns the
        existing thread if one exists) or a group conversation (2+ recipients).
        An optional `body` (+ `attachments[]`) posts the first message inline.
        Governance (`who_can_initiate`, group-DMs-enabled, participant cap) is
        enforced server-side.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: Recipient user ids (excluding the caller).
                title:
                  type: string
                  description: Optional group title.
                body:
                  type: string
                  description: Optional first message.
                attachments:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Optional first-message file attachments.
                context_workspace_id:
                  type: integer
                  description: Optional origin workspace id.
      responses:
        '201':
          description: Conversation created (or existing 1:1 returned)
          content:
            application/json:
              schema:
                type: object
                properties:
                  thread:
                    "$ref": "#/components/schemas/MessagingThreadDetail"
        '401':
          description: Unauthorized
        '403':
          description: Not allowed to start conversations / group DMs disabled
        '422':
          description: No recipients / participant cap exceeded
  "/messaging/threads/unread_count":
    get:
      tags:
      - Messaging
      summary: Total unread DM badge
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unread total
          content:
            application/json:
              schema:
                type: object
                properties:
                  unread:
                    type: integer
        '401':
          description: Unauthorized
  "/messaging/recipients":
    get:
      tags:
      - Messaging
      summary: Recipient picker (searchable org users)
      description: |
        Searchable, recent-DM-boosted list of users the caller can message —
        the same ranking the web "New Message" picker uses. Scoped to the
        caller's business.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: Search term (name / email / employee id / phone / title).
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      responses:
        '200':
          description: Ranked user results
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingRecipient"
                  pagination:
                    type: object
                    properties:
                      more:
                        type: boolean
        '401':
          description: Unauthorized
  "/messaging/threads/{id}":
    get:
      tags:
      - Messaging
      summary: Open a conversation (marks it read)
      description: Returns the conversation with its participants and marks it read
        for the caller.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Conversation detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  thread:
                    "$ref": "#/components/schemas/MessagingThreadDetail"
        '401':
          description: Unauthorized
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/read":
    patch:
      tags:
      - Messaging
      summary: Mark a conversation read
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Marked read
        '404':
          description: Conversation not found
  "/messaging/threads/{id}/archive":
    post:
      tags:
      - Messaging
      summary: Archive a conversation (per-user)
      description: |
        Archives the conversation for the CALLER only — stamps `archived_at` on
        the caller's own participant row so it drops out of their live list
        (`GET /messaging/threads`) and appears under `?archived=true`. Per-user
        and never touches another participant's row or the thread itself. An
        archived conversation resurfaces automatically the next time anyone posts
        to it; use `POST /messaging/threads/{id}/unarchive` to bring it back
        manually. Same semantics as the web Messages "Archive" action.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/unarchive":
    post:
      tags:
      - Messaging
      summary: Unarchive a conversation (per-user)
      description: |
        Clears the caller's archive (`archived_at` → null) so the conversation
        returns to their live list (`GET /messaging/threads`). The explicit
        inverse of `POST /messaging/threads/{id}/archive` — archived conversations
        also resurface implicitly when anyone posts a new message, but this lets a
        caller pull one back without waiting. Per-user (never touches another
        participant's row) and idempotent — unarchiving a conversation that isn't
        archived still returns 200.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Unarchived
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/rename":
    patch:
      tags:
      - Messaging
      summary: Rename a group conversation
      description: |
        Sets a custom title on a GROUP conversation (1:1 conversations cannot be
        renamed → 422). The title is trimmed and capped at 120 characters; a
        blank title clears the custom name and the conversation falls back to its
        participant-derived label. Same semantics as the web Messages
        "Rename conversation" action. Returns the detailed thread payload.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 120
                  description: New conversation name. Blank clears the custom name.
                  example: Launch War Room
      responses:
        '200':
          description: Renamed — detailed thread payload
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
        '422':
          description: Not renameable (1:1 conversation) or title too long
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: thread_not_renamed
  "/messaging/threads/{id}/leave":
    post:
      tags:
      - Messaging
      summary: Leave a group conversation
      description: |
        Removes the CALLER's own participant row from a GROUP conversation. The
        thread itself is untouched — remaining participants keep the full history
        and the leaver simply stops seeing (and being notified about) the
        conversation. 1:1 conversations cannot be left → 422; use archive to hide
        a 1:1 instead. Same semantics as the web Messages "Leave
        conversation" action. Removes only the caller — there is no endpoint to
        remove another member.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Left the conversation
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
        '422':
          description: Not leaveable (1:1 conversation)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: thread_not_left
  "/messaging/threads/{id}/mute":
    post:
      tags:
      - Messaging
      summary: Mute a conversation (per-user)
      description: |
        Mutes the conversation for the CALLER only — writes `muted_until` on the
        caller's own participant row (≈ 100 years out). While muted the message
        notifier skips the caller and the conversation drops out of the unread
        badge total; per-row unread counts are unaffected. Same semantics as the
        web Messages 3-dot "Mute conversation" action. Idempotent.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Muted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  muted:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/unmute":
    post:
      tags:
      - Messaging
      summary: Unmute a conversation (per-user)
      description: |
        Clears the caller's mute (`muted_until` → null) so notifications and
        badge counts resume. Same semantics as the web "Unmute" action.
        Idempotent — unmuting an already-unmuted conversation still returns 200.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Unmuted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  muted:
                    type: boolean
                    example: false
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/add_participants":
    post:
      tags:
      - Messaging
      summary: Add members to a group conversation
      description: |
        Adds one or more people to a GROUP conversation (1:1 conversations
        cannot take new members → 422). Resolution is STRICT: if any requested
        `user_id` doesn't resolve inside the caller's business the whole call
        fails and nobody is added — never a partial add. Users already in the
        conversation are skipped gracefully (re-adding is a no-op, still 200).
        The group participant cap is enforced across existing + new members.
        Same semantics as the web Messages "Add people" action. Returns the
        refreshed participant roster.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_ids
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: IDs of the users to add. Must all resolve inside the
                    caller's business.
                  example:
                  - 42
                  - 57
      responses:
        '200':
          description: Members added — refreshed participant roster
          content:
            application/json:
              schema:
                type: object
                properties:
                  participants:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingParticipant"
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
        '422':
          description: Not a group conversation, no/invalid user_ids, or participant
            cap exceeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: participants_not_added
  "/messaging/threads/{id}/participants":
    get:
      tags:
      - Messaging
      summary: List the conversation's users
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Participants
          content:
            application/json:
              schema:
                type: object
                properties:
                  participants:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingParticipant"
        '404':
          description: Conversation not found
  "/messaging/threads/{thread_id}/messages":
    get:
      tags:
      - Messaging
      summary: List messages in a conversation
      description: |
        Message history, newest first.

        Two modes. Without `before_id` this is page/offset based and the
        response carries `meta`. Offset paging is not stable over a live
        conversation — a message posted between two fetches shifts the window
        and the next page repeats rows, one deleted between fetches skips a row
        — so pass `before_id` to walk the history with a cursor instead: the
        `per_page` messages strictly older than that message id. The cursor
        response carries `cursor` instead of `meta`; feed `cursor.next_before_id`
        back as `before_id` and stop when `cursor.has_more` is false.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        description: Ignored when `before_id` is given.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 30
          minimum: 1
          maximum: 100
      - name: before_id
        in: query
        description: Message id cursor. Returns the `per_page` messages older than
          this id, newest first. Optional; omit for page-based paging.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Paginated messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingMessage"
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/MessagingPaginationMeta"
                  cursor:
                    type: object
                    description: Present only when `before_id` was given (replaces
                      `meta`).
                    properties:
                      before_id:
                        type: integer
                      next_before_id:
                        type: integer
                        nullable: true
                        description: Oldest id in this window; pass back as `before_id`.
                          Null when the window is empty.
                      has_more:
                        type: boolean
        '401':
          description: Unauthorized
        '404':
          description: Conversation not found
    post:
      tags:
      - Messaging
      summary: Post a message (text + attachments)
      description: |
        multipart/form-data — `body` text and/or repeated `attachments[]` file
        parts. Attachments are dropped if file uploads are disabled for the
        business; an attachment-only post then fails the empty-message guard.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                body:
                  type: string
                attachments:
                  type: array
                  items:
                    type: string
                    format: binary
      responses:
        '201':
          description: Message posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '401':
          description: Unauthorized
        '404':
          description: Conversation not found
        '422':
          description: Empty message / not a participant
  "/messaging/threads/{thread_id}/messages/{id}":
    patch:
      tags:
      - Messaging
      summary: Edit a message (author only)
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
      responses:
        '200':
          description: Message updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '404':
          description: Message not found or not the caller's
        '422':
          description: Blank body
    delete:
      tags:
      - Messaging
      summary: Delete a message (author only, soft delete)
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Message soft-deleted (returns the tombstoned message)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '404':
          description: Message not found or not the caller's
  "/messaging/threads/{thread_id}/messages/{message_id}/attachments/{id}":
    delete:
      tags:
      - Messaging
      summary: Remove one attachment (author only)
      description: DMs are private — admins/managers do NOT bypass the author check.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Attachment removed
        '403':
          description: Not the caller's message
        '404':
          description: Attachment or conversation not found
  "/chat/rooms":
    get:
      tags:
      - Chat
      summary: List the caller's chat rooms
      description: |
        Paginated list of the caller's active rooms (direct / group / channel),
        with unread counts, pin/mute state, a compact last-message preview, and
        per-room capability flags.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        description: Filter by room type. Omit for all rooms.
        schema:
          type: string
          enum:
          - direct
          - group
          - channel
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Paginated room list
          content:
            application/json:
              schema:
                type: object
                properties:
                  rooms:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatRoom"
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/ChatPaginationMeta"
                    - type: object
                      properties:
                        scope:
                          type: string
                          nullable: true
                          enum:
                          - direct
                          - group
                          - channel
                          description: |
                            The room-type filter ACTUALLY applied, normalized
                            (case and surrounding whitespace are forgiven), or
                            null when none was. An unrecognized `scope` fails
                            open to the full list rather than erroring - read
                            this key rather than assuming the request's own
                            value was honoured.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or insufficient scope
    post:
      tags:
      - Chat
      summary: Create a room (DM or group/channel)
      description: |
        Creates a direct message (`room_type: direct` + `other_user_id`) or a
        group/channel (`room_type: group|channel` + `name` + `member_user_ids`).
        DMs are idempotent — an existing DM with that user is returned with
        200 instead of 201. Gated by the `direct_messages_enabled` /
        `group_chats_enabled` business settings.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - room_type
              properties:
                room_type:
                  type: string
                  enum:
                  - direct
                  - group
                  - channel
                other_user_id:
                  type: integer
                  description: DM peer (direct rooms only).
                name:
                  type: string
                  maxLength: 255
                  description: Group/channel name (required for non-DM rooms).
                member_user_ids:
                  type: array
                  items:
                    type: integer
                  description: Initial members (group/channel; excludes the caller).
                photo:
                  type: string
                  format: binary
                  description: Optional group/channel photo.
      responses:
        '200':
          description: Existing direct room returned (idempotent DM create)
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '201':
          description: Room created
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '400':
          description: Missing member_user_ids / blank or over-long name
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or DMs/group chats disabled
        '422':
          description: Peer is self / users not in this business
  "/chat/rooms/{id}":
    get:
      tags:
      - Chat
      summary: Room detail + message timeline
      description: |
        Returns the room plus a message page ordered newest-first, paginated by
        message id (`before_id` cursor). The initial page (`before_id=0`) also
        marks the room read for the caller.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: before_id
        in: query
        description: Return messages strictly older than this id (0 = newest page).
        schema:
          type: integer
          default: 0
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      - name: include_thread_replies
        in: query
        description: Include thread replies inline in the timeline.
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Room + message page
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  has_more:
                    type: boolean
                  oldest_message_id:
                    type: integer
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    patch:
      tags:
      - Chat
      summary: Update a group/channel (room admin only)
      description: |
        Rename and/or change the photo of a group/channel. Direct rooms cannot
        be edited. At least one of `name`, `photo`, `remove_photo` is required.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                photo:
                  type: string
                  format: binary
                  description: PNG/JPEG/GIF/WebP, max 10 MB.
                remove_photo:
                  type: boolean
                  description: Clear the existing photo.
      responses:
        '200':
          description: Room updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '403':
          description: Not a member or not a room admin
        '404':
          description: Room not found
        '422':
          description: Direct room / no updates given / invalid name or photo
    delete:
      tags:
      - Chat
      summary: Leave a room (or hard-delete with for_all=true)
      description: |
        Default is a per-user soft leave (no-op for DMs). `for_all=true`
        hard-deletes the room for everyone — room creator or chat app admin
        only.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: for_all
        in: query
        description: Hard-delete the room for all members (creator/admin only).
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Left or deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  archived:
                    type: boolean
                  left:
                    type: boolean
                  deleted:
                    type: boolean
        '403':
          description: Not a member
          or not creator/admin for for_all:
        '404':
          description: Room not found
        '422':
          description: Direct rooms cannot be hard-deleted
  "/chat/rooms/mark_all_read":
    post:
      tags:
      - Chat
      summary: Mark every conversation read (conversation-list "Mark all as read")
      description: |-
        Bulk twin of `PATCH /chat/rooms/{id}/mark_read`, backing the conversation list's "⋯ → Mark all as read". Sweeps every room the caller's list shows — not one page and not one filter — advancing each read cursor and clearing each unread count in a single statement. Idempotent: a second call with nothing unread returns `marked_count: 0`.

        `marked_count` / `room_ids` report the conversations that were actually SHOWING as unread, so a client can quote the number and clear exactly those badges. Rooms whose cursor merely caught up (their newest message is the caller's own) are swept too but are not counted.

        `awaiting_ack_room_ids` names the rooms that still carry a pending Important or Read-Receipt acknowledgement. Marking read deliberately does NOT acknowledge on the user's behalf, so those rooms keep their `has_important_messages` / `has_read_receipt_messages` flag on the next list fetch — clients should explain that rather than treat it as the action failing.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Sweep applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  marked_count:
                    type: integer
                    description: Conversations that were unread and now are not
                  room_ids:
                    type: array
                    items:
                      type: integer
                    description: Ids of those conversations
                  awaiting_ack_room_ids:
                    type: array
                    items:
                      type: integer
                    description: Rooms still holding an unacknowledged Important /
                      Read-Receipt message
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
          or token lacks write:chat:
  "/chat/rooms/{id}/mark_read":
    patch:
      tags:
      - Chat
      summary: Mark all messages in a room read (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Read cursor advanced (noop=true when nothing was unread)
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  noop:
                    type: boolean
                  last_read_message_id:
                    type: integer
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/mentionable_users":
    get:
      tags:
      - Chat
      summary: Mention typeahead within a room
      description: Search the room's members by name/email for @-mention typeahead.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: q
        in: query
        description: Search query (min 2 chars to filter).
        schema:
          type: string
      responses:
        '200':
          description: Matching members
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatUserSummary"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
        '404':
          description: Room not found
  "/chat/rooms/{id}/files":
    get:
      tags:
      - Chat
      summary: Paginated attachment list for a room
      description: All file/media attachments shared in the room, newest first.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 30
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Paginated file list
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        message_id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                        preview_url:
                          type: string
                          nullable: true
                        uploaded_by:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/ChatUserSummary"
                        created_at:
                          type: string
                          format: date-time
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/ChatPaginationMeta"
                    - type: object
                      properties:
                        has_more:
                          type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/pin":
    post:
      tags:
      - Chat
      summary: Pin a room for the caller (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Room pinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  pinned_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    delete:
      tags:
      - Chat
      summary: Unpin a room for the caller (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Room unpinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  pinned_at:
                    type: string
                    nullable: true
                    description: Always null after unpin.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/mute":
    post:
      tags:
      - Chat
      summary: Mute a room for the caller
      description: |
        Mute until a specific time (`muted_until` in epoch milliseconds) or
        indefinitely (0 / null / omitted).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                muted_until:
                  type: integer
                  nullable: true
                  description: Epoch milliseconds; 0/null = mute indefinitely.
      responses:
        '200':
          description: Room muted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  muted_until:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    delete:
      tags:
      - Chat
      summary: Unmute a room for the caller (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Room unmuted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  muted_until:
                    type: string
                    nullable: true
                    description: Always null after unmute.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/important_messages":
    get:
      tags:
      - Chat
      summary: Unread important messages in a room
      description: |
        Important messages (`ack_type=important`) the caller has not yet
        acknowledged — powers the client's "unread important" modal.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      responses:
        '200':
          description: Unacknowledged important messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{room_id}/members":
    get:
      tags:
      - Chat
      summary: List room members
      description: |
        Active members, admins first, then by join date, then by id. Paginated:
        the default page size (200) is larger than any roster we have seen, so
        an existing caller that ignores `page` is not truncated today — but a
        roster above the page size IS truncated, and `meta` is the only thing
        that says so. This block was undocumented until 2026-09-02 while the
        endpoint was already paginating, which is why it is spelled out here.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 200
          minimum: 1
          maximum: 500
      responses:
        '200':
          description: Paginated member list
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMember"
                  meta:
                    "$ref": "#/components/schemas/ChatPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    post:
      tags:
      - Chat
      summary: Add members to a group/channel (room admin only)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_ids
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: Users to add (member_user_ids also accepted).
      responses:
        '200':
          description: Members added (partitioned by outcome)
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  added:
                    type: array
                    items:
                      type: integer
                  rejoined:
                    type: array
                    items:
                      type: integer
                  already_member:
                    type: array
                    items:
                      type: integer
        '401':
          description: Unauthorized
        '403':
          description: Not a member or not a room admin
        '404':
          description: Room not found
        '422':
          description: Direct room / empty user_ids / users not in business
  "/chat/rooms/{room_id}/members/{id}":
    patch:
      tags:
      - Chat
      summary: Change a member's role (room admin only)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Membership id (not user id).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - role
              properties:
                role:
                  type: string
                  enum:
                  - admin
                  - member
      responses:
        '200':
          description: Role updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/ChatMember"
        '403':
          description: Not a member or not a room admin
        '404':
          description: Room or member not found
        '422':
          description: Invalid role / cannot demote the last admin
    delete:
      tags:
      - Chat
      summary: Remove a member (self, or any member as room admin)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Membership id (not user id).
      responses:
        '200':
          description: Member removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  membership_id:
                    type: integer
                  removed_user_id:
                    type: integer
                  self_left:
                    type: boolean
        '403':
          description: Not authorized to remove this member
        '404':
          description: Room or member not found
        '422':
          description: Direct room / creator leaving / last admin
  "/chat/rooms/{room_id}/media":
    post:
      tags:
      - Chat
      summary: Start a direct-to-S3 media upload
      description: |
        Creates an unattached upload row and returns a presigned S3 PUT URL
        (1-hour TTL). The client PUTs the bytes to `upload_url`, then calls
        the `/complete` endpoint, and finally references the media id in
        `media_ids` when posting a message.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - media_type
              - mime_type
              - original_filename
              - file_size_bytes
              properties:
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                mime_type:
                  type: string
                  example: image/jpeg
                original_filename:
                  type: string
                file_size_bytes:
                  type: integer
                width_px:
                  type: integer
                  description: Images/video only.
                height_px:
                  type: integer
                  description: Images/video only.
      responses:
        '201':
          description: Upload initiated
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    "$ref": "#/components/schemas/ChatMedia"
                  upload_url:
                    type: string
                    description: Presigned S3 PUT URL.
                  storage_key:
                    type: string
                  expires_in:
                    type: integer
                    description: Seconds until upload_url expires.
        '403':
          description: Not a room member or file uploads disabled
        '404':
          description: Room not found
        '422':
          description: Blocked content type/extension
          too large:
          or bad dimensions:
  "/chat/rooms/{room_id}/media/{id}/complete":
    patch:
      tags:
      - Chat
      summary: Finalize an uploaded media file (uploader only)
      description: |
        Call after the S3 PUT succeeds. Images/files become `ready`
        immediately; videos stay `pending` until transcoding completes.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Upload finalized
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    "$ref": "#/components/schemas/ChatMedia"
        '403':
          description: Not a room member
        '404':
          description: Room or media not found (or not the caller's upload)
  "/chat/rooms/{room_id}/media/{id}":
    delete:
      tags:
      - Chat
      summary: Discard an in-flight upload (uploader only)
      description: Only unattached uploads can be removed — once a message references
        the media it is permanent.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Upload discarded
        '403':
          description: Not a room member
        '404':
          description: Room or media not found (or not the caller's upload)
        '422':
          description: Media already attached to a sent message
  "/chat/rooms/{room_id}/messages":
    get:
      tags:
      - Chat
      summary: Room message history (paged)
      description: |
        The history pager. Shares one window contract with
        `GET /chat/rooms/{id}` so both surfaces stay in lockstep. Omit every
        cursor for the newest page; pass `before_id` to walk strictly older
        history; pass `around_id` to land on a message of any age in one
        request (it wins over `before_id`). Opening the newest page marks the
        room read - paging with a cursor deliberately does not, so walking
        history never bumps `last_read` forward.

        `has_more` is derived by over-fetching one row, so a room holding
        exactly `limit` messages correctly reports `has_more: false` rather
        than offering a page that comes back empty.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: before_id
        in: query
        description: Return messages strictly older than this id (exclusive).
        schema:
          type: integer
      - name: around_id
        in: query
        description: |
          Jump-to-message window - roughly half the page at or before this id
          and half after it, in chronological order. Takes precedence over
          `before_id`.
        schema:
          type: integer
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
      - name: include_thread_replies
        in: query
        description: |
          Surface inline quote-replies in the timeline too. Default false
          keeps the top-level-only contract thread-based clients expect.
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Message window
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  has_more:
                    type: boolean
                  oldest_message_id:
                    type: integer
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    post:
      tags:
      - Chat
      summary: Post a message (asynchronous)
      description: |
        Queues the message for creation and Pusher broadcast (202). Provide
        `client_uuid` to reconcile the optimistic client bubble with the
        broadcast. Attach pre-uploaded media via `media_ids` (max 10).
        `ack_type` marks the message as an Important Message or Read Receipt
        Request (feature-gated; not allowed in channels; important messages
        cannot carry attachments).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: Message text — required unless media_ids is present.
                parent_message_id:
                  type: integer
                  description: Message to quote/reply to (one-level inline quote).
                client_uuid:
                  type: string
                  description: Client-generated UUID for dedup / optimistic UI.
                media_ids:
                  type: array
                  maxItems: 10
                  items:
                    type: integer
                  description: Completed, unattached upload ids from POST /media.
                ack_type:
                  type: string
                  enum:
                  - important
                  - read_receipt
                  description: Request explicit acknowledgement (feature-gated).
      responses:
        '202':
          description: Message queued for delivery
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: queued
                  client_uuid:
                    type: string
                    nullable: true
                  room_id:
                    type: integer
        '403':
          description: Not a room member
        '422':
          description: Blank body+media / invalid media_ids / disallowed ack_type
            / bad parent
  "/chat/rooms/{room_id}/messages/{id}":
    patch:
      tags:
      - Chat
      summary: Edit a message (author only, 15-minute window)
      description: |
        Edits the message body and broadcasts the update. Gated by the
        `allow_edit_chat_messages` business setting; attachment-only messages
        are not editable.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
      responses:
        '200':
          description: Message updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not author / editing disabled / outside edit window
        '404':
          description: Message not found
        '422':
          description: Blank body
    delete:
      tags:
      - Chat
      summary: Delete a message (author or room admin, soft delete)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Message soft-deleted (returns the tombstoned message)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not the author and not a room admin
        '404':
          description: Message not found
  "/chat/rooms/{room_id}/messages/{message_id}/reactions":
    post:
      tags:
      - Chat
      summary: React to a message (idempotent)
      description: Re-posting the same emoji returns 200 instead of duplicating.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: One emoji from the supported set.
      responses:
        '200':
          description: Reaction already existed (idempotent repeat)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reaction:
                    "$ref": "#/components/schemas/ChatReaction"
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '201':
          description: Reaction added
          content:
            application/json:
              schema:
                type: object
                properties:
                  reaction:
                    "$ref": "#/components/schemas/ChatReaction"
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not a room member
        '404':
          description: Room or message not found
        '422':
          description: Unsupported emoji
  "/chat/rooms/{room_id}/messages/{message_id}/reactions/{id}":
    delete:
      tags:
      - Chat
      summary: Remove the caller's reaction
      description: |
        `id` accepts either the numeric reaction id or the emoji string itself
        (only the caller's own reaction is matched by emoji).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Reaction id or emoji string.
      responses:
        '200':
          description: Reaction removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not a room member or not the reaction owner
        '404':
          description: Room
          message:
          or reaction not found:
  "/chat/rooms/{room_id}/messages/{message_id}/thread":
    get:
      tags:
      - Chat
      summary: Thread view (parent + replies)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Parent message with its replies (oldest first)
          content:
            application/json:
              schema:
                type: object
                properties:
                  parent:
                    "$ref": "#/components/schemas/ChatMessage"
                  replies:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  reply_count:
                    type: integer
        '403':
          description: Not a room member
        '404':
          description: Parent not found
          deleted:
          or itself a reply:
  "/chat/rooms/{room_id}/messages/{message_id}/acknowledge":
    post:
      tags:
      - Chat
      summary: Acknowledge an important / read-receipt message (idempotent)
      description: |
        Explicit per-message acknowledgement for messages posted with an
        `ack_type`. A repeat call is a no-op (`noop: true`).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  acknowledged_at:
                    type: string
                    format: date-time
                  workflow_participants_count:
                    type: integer
                    description: Eligible recipients who have acknowledged.
                  ack_eligible_count:
                    type: integer
                    description: Total eligible recipients (excludes the author).
                  noop:
                    type: boolean
        '403':
          description: Not a room member
        '404':
          description: Room or message not found
        '422':
          description: Message does not require acknowledgement
  "/chat/rooms/{room_id}/messages/{message_id}/acknowledgements":
    get:
      tags:
      - Chat
      summary: '"Read by X/Y" acknowledgement report'
      description: Partitions eligible recipients into acknowledged and pending.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledgement report
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  ack_type:
                    type: string
                    nullable: true
                    enum:
                    - important
                    - read_receipt
                    -
                  acknowledged:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/ChatUserSummary"
                      - type: object
                        properties:
                          user_id:
                            type: integer
                          acknowledged_at:
                            type: string
                            format: date-time
                  pending:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/ChatUserSummary"
                      - type: object
                        properties:
                          user_id:
                            type: integer
        '403':
          description: Not a room member
        '404':
          description: Room or message not found
  "/chat/start_chat":
    post:
      tags:
      - Chat
      summary: Find-or-create a conversation from a user selection
      description: |
        One selected user → DM; two or more → group. Reuses an existing room
        with the identical member set unless `create_new_conv_always` is true.
        Group name is auto-derived from member first names when omitted.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - selected_user_ids
              properties:
                selected_user_ids:
                  type: array
                  items:
                    type: integer
                name:
                  type: string
                  description: Optional group name (ignored for DMs).
                create_new_conv_always:
                  type: boolean
                  description: Skip dedup and always create a new room.
      responses:
        '200':
          description: Room found or created
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  created:
                    type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or DMs/group chats disabled
        '422':
          description: No users selected / self-only / users not in business
  "/chat/users/search":
    get:
      tags:
      - Chat
      summary: User search for starting chats (recent-DM boosted)
      description: |
        Searches business users by name, email, employee id, job title, office
        location, and phone; users the caller recently messaged rank higher.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        schema:
          type: string
      - name: active_only
        in: query
        schema:
          type: boolean
          default: true
      - name: roles[]
        in: query
        description: |
          Role filter. Honoured ONLY for a caller who is manager-or-above in
          the business; for anyone else it is silently ignored (the request
          still succeeds and returns the default set, so pickers never
          break). Defaults to member/manager/admin/super_admin; guests are
          excluded either way.
        schema:
          type: array
          items:
            type: string
      - name: page
        in: query
        description: |
          Page 1 additionally carries the caller's recent-DM contacts, prepended
          and de-duplicated against the paged window, so it returns more rows
          than `per_page`. Size buffers off the returned array, not off
          `page x 50`.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: all
        in: query
        description: |
          Return up to 500 ranked matches in one response; `page` is ignored.
          NOT "all matches without paging" - that wording was wrong. When the
          directory is larger than the cap, `pagination.more` is true and the
          remainder is only reachable by narrowing `q`.
        schema:
          type: boolean
      responses:
        '200':
          description: Ranked user matches
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        text:
                          type: string
                          description: Same as name (typeahead convention).
                        email:
                          type: string
                        avatar_url:
                          type: string
                        meta:
                          type: object
                          description: Context metadata for the picker.
                  pagination:
                    type: object
                    properties:
                      more:
                        type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/mentions":
    get:
      tags:
      - Chat
      summary: The caller's mentions inbox
      description: |
        Cross-room mentions of the caller (or a single room's with `room_id`),
        newest first, cursor-paginated by mention id.
      security:
      - BearerAuth: []
      parameters:
      - name: before_id
        in: query
        description: Return mentions strictly older than this id (0 = newest page).
        schema:
          type: integer
          default: 0
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      - name: room_id
        in: query
        description: Scope to one conversation.
        schema:
          type: integer
      responses:
        '200':
          description: Mention page
          content:
            application/json:
              schema:
                type: object
                properties:
                  mentions:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        message_id:
                          type: integer
                        room_id:
                          type: integer
                        user_id:
                          type: integer
                          description: The MENTIONED user (always the caller).
                        sender_id:
                          type: integer
                          nullable: true
                          description: Author of the mentioning message.
                        sender_name:
                          type: string
                          nullable: true
                          description: Author's display name, for the inbox row.
                        sender_avatar_url:
                          type: string
                          nullable: true
                          description: Author's avatar (falls back to a generated
                            initials image when no photo is set).
                        sender_avatar_updated_at:
                          type: integer
                          nullable: true
                          description: Epoch seconds the author's avatar last changed
                            — cache-bust token for URL-caching clients.
                        body_excerpt:
                          type: string
                        created_at:
                          type: string
                          format: date-time
                  has_more:
                    type: boolean
                  oldest_mention_id:
                    type: integer
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/search":
    get:
      tags:
      - Chat
      summary: Message search (full-text or semantic)
      description: |
        Searches message content across the caller's rooms. `mode=semantic`
        adds vector-embedding scoring when the business has semantic search
        enabled (falls back to FTS otherwise).
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
      - name: mode
        in: query
        schema:
          type: string
          enum:
          - fts
          - semantic
          default: fts
      - name: room_id
        in: query
        schema:
          type: integer
      - name: sender_id
        in: query
        schema:
          type: integer
      - name: date_from
        in: query
        schema:
          type: string
          format: date
      - name: date_to
        in: query
        schema:
          type: string
          format: date
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        room_id:
                          type: integer
                        user_id:
                          type: integer
                        body:
                          type: string
                        parent_message_id:
                          type: integer
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        snippet_html:
                          type: string
                          nullable: true
                          description: Highlighted match excerpt.
                  mode:
                    type: string
                    enum:
                    - fts
                    - semantic
                  has_more:
                    type: boolean
                  query:
                    type: string
                  semantic_available:
                    type: boolean
                  semantic_attempted:
                    type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/search/senders":
    get:
      tags:
      - Chat
      summary: Sender autocomplete for the search filter
      description: Users who have sent messages in the caller's active rooms.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: |
          Partial name/email. Below 2 characters NO senders are returned -
          the endpoint does not fall back to an unfiltered alphabetical list,
          because a picker renders those rows as if they were matches.
        schema:
          type: string
      responses:
        '200':
          description: Matching senders
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatUserSummary"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/search/conversations":
    get:
      tags:
      - Chat
      summary: Find conversations by name or message content
      description: |
        Searches the caller's active conversations by group/channel name, DM
        partner name, or full message history (max 50 rooms). Message-content
        matching uses the same full-text search as `/chat/search`, but returns
        room objects so chat-list clients can render the matching conversation.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Conversations matching by name or message content
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatRoom"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/settings":
    get:
      tags:
      - Chat
      summary: Business chat settings + caller admin flag
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings:
                    "$ref": "#/components/schemas/ChatSettings"
                  admin:
                    type: boolean
                    description: Whether the caller can update settings.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
    patch:
      tags:
      - Chat
      summary: Update business chat settings (admin only)
      description: Send any subset of keys; omitted keys are unchanged. Integer values
        are clamped to safe ranges.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ChatSettings"
      responses:
        '200':
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings:
                    "$ref": "#/components/schemas/ChatSettings"
        '401':
          description: Unauthorized
        '403':
          description: Not an admin or chat app disabled
        '422':
          description: Validation failure
  "/chat/presence/heartbeat":
    post:
      tags:
      - Chat
      summary: Presence heartbeat
      description: Marks the caller online; clients call this periodically.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/presence/online":
    get:
      tags:
      - Chat
      summary: Who is online
      description: |
        Either room-scoped (`room_id` — caller must be a member) or an explicit
        `user_ids` list (comma-separated, max 200).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: query
        schema:
          type: integer
      - name: user_ids
        in: query
        description: Comma-separated user ids (max 200); used when room_id is omitted.
        schema:
          type: string
      responses:
        '200':
          description: Online user ids
          content:
            application/json:
              schema:
                type: object
                properties:
                  online_user_ids:
                    type: array
                    items:
                      type: integer
                  room_id:
                    type: integer
                    description: Echoed only in room_id mode.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a member of the room
        '404':
          description: Room not found
        '422':
          description: More than 200 user_ids
  "/chat/pusher/auth":
    post:
      tags:
      - Chat
      summary: Authorize a Pusher channel subscription
      description: |
        Validates the channel against the caller's memberships and returns a
        signed Pusher auth token. Supported channels: `private-room-<id>`,
        `private-user-inbox-<user_id>-business-<business_id>`, and
        `presence-room-<id>` (presence responses also include `channel_data`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
              - channel_name
              - socket_id
              properties:
                channel_name:
                  type: string
                  example: private-room-42
                socket_id:
                  type: string
      responses:
        '200':
          description: Subscription authorized
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth:
                    type: string
                  channel_data:
                    type: string
                    description: JSON-encoded user info — presence channels only.
        '400':
          description: Missing channel_name or socket_id
        '401':
          description: Unauthorized
        '403':
          description: Unsupported channel
          not a member:
          or cross-business mismatch:
        '503':
          description: Pusher not configured on this server
  "/chat/pusher/config":
    get:
      tags:
      - Chat
      summary: Pusher bootstrap config (legacy flat shape)
      description: Backward-compatible alias — new clients should call GET /chat/config.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Pusher client config
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatPusherConfig"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/config":
    get:
      tags:
      - Chat
      summary: Unified client bootstrap (pusher + chat settings)
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Combined config
          content:
            application/json:
              schema:
                type: object
                properties:
                  pusher:
                    "$ref": "#/components/schemas/ChatPusherConfig"
                  chat_settings:
                    "$ref": "#/components/schemas/ChatSettings"
                  ask_ai_account:
                    allOf:
                    - "$ref": "#/components/schemas/ChatAskAiIdentity"
                    nullable: true
                    description: The @Ask AI MENTION principal. Present only while
                      the tenant's "Enable @Ask AI in chat conversations" setting
                      (chat_settings.ask_ai_enabled) is on. null ⇒ do not offer the
                      @Ask AI mention. Carries the extra history_search flag.
                  ask_ai_chat:
                    allOf:
                    - "$ref": "#/components/schemas/ChatAskAiIdentity"
                    nullable: true
                    description: The PINNED private Ask AI conversation (each user's
                      own assistant chat). Gated on this USER's access to the Ask
                      AI app — NOT on chat_settings.ask_ai_enabled, which controls
                      only the in-room mention. null ⇒ do not show the pinned Ask
                      AI chat. Read this key for the pinned entry; never infer it
                      from ask_ai_account. `id` is null until the assistant principal
                      has been provisioned — render the name/avatar fallbacks as usual.
                  reactions:
                    type: array
                    description: Server-driven reaction set (also at GET /chat/reactions).
                    items:
                      "$ref": "#/components/schemas/ChatReaction"
                  klipy:
                    type: object
                    description: GIF-picker (KLIPY) bootstrap. enabled=false ⇒ hide
                      the picker.
                    properties:
                      api_key:
                        type: string
                        nullable: true
                      base_url:
                        type: string
                      enabled:
                        type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/rfp_desk/answers":
    get:
      tags:
      - RFP Desk
      summary: List answer-library entries
      description: |
        Approved, reusable answers to RFP questions. Requires the RFP Desk contributor (or admin) grant — the same gate as the Answer Library tab.
        Filter by `category`, `status` (`active`, `archived`, `stale` — not reviewed in 12 months) and a free-text `q` over question and answer text.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        schema:
          type: string
      - name: category
        in: query
        schema:
          type: string
          enum:
          - technical
          - compliance
          - commercial
          - delivery
          - other
      - name: status
        in: query
        schema:
          type: string
          enum:
          - active
          - archived
          - stale
      - name: mine
        in: query
        description: Only answers the caller is named owner of
        schema:
          type: boolean
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Number of records per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated answers
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RfpAnswer"
                  meta:
                    type: object
                    description: Standard pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items across all pages
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 15
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Number of items per page
                        example: 10
                      has_next:
                        type: boolean
                        description: Whether there is a next page
                        example: true
                      has_prev:
                        type: boolean
                        description: Whether there is a previous page
                        example: false
                      segment_counts:
                        type: object
                        description: |-
                          Per-segment row counts for the screen's filter-tab badges (exact, not capped at per_page) so a client can label every tab from the first response without fetching each one. Each count equals the matching status_type filter's total_count by construction. Keys depend on the feed (the two screens have different tabs):
                            - Personal feed (team omitted/false): active, under_review, completed.
                            - Team feed (team=true): all, in_progress, completed, failed, overdue.
                          Present on both feeds.
                        additionalProperties:
                          type: integer
                        example:
                          active: 7
                          under_review: 0
                          completed: 23
                  total_count:
                    type: integer
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
    post:
      tags:
      - RFP Desk
      summary: Add an answer to the library
      description: Creates an approved answer. Questions are unique per workspace
        (case-insensitive); a duplicate returns 422.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RfpAnswerInput"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/answers/{id}":
    get:
      tags:
      - RFP Desk
      summary: Get one answer
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The answer
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
    patch:
      tags:
      - RFP Desk
      summary: Update an answer
      description: Any subset of the input fields. Setting `active` to true reactivates
        an archived answer.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RfpAnswerInput"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/answers/{id}/review":
    post:
      tags:
      - RFP Desk
      summary: Mark an answer as reviewed
      description: Confirms the answer is still true without editing it — resets the
        12-month staleness clock.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Reviewed
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
  "/rfp_desk/answers/{id}/archive":
    post:
      tags:
      - RFP Desk
      summary: Retire an answer
      description: Sets `active` to false. The answer stops being offered to drafters;
        sections that already reused it keep their provenance. There is no delete
        over the API.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
  "/rfp_desk/responses":
    get:
      tags:
      - RFP Desk
      summary: List RFP responses
      description: 'Responses the caller could open in the browser: the participant
        floor (responsible, collaborator, section owner, creator) plus the workspace''s
        who-can-view-all-responses setting. Filter by `status`.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        schema:
          type: string
          enum:
          - draft
          - on_hold
          - in_review
          - approved
          - submitted
          - declined
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Number of records per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated responses
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RfpResponseSummary"
                  meta:
                    type: object
                    description: Standard pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items across all pages
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 15
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Number of items per page
                        example: 10
                      has_next:
                        type: boolean
                        description: Whether there is a next page
                        example: true
                      has_prev:
                        type: boolean
                        description: Whether there is a previous page
                        example: false
                      segment_counts:
                        type: object
                        description: |-
                          Per-segment row counts for the screen's filter-tab badges (exact, not capped at per_page) so a client can label every tab from the first response without fetching each one. Each count equals the matching status_type filter's total_count by construction. Keys depend on the feed (the two screens have different tabs):
                            - Personal feed (team omitted/false): active, under_review, completed.
                            - Team feed (team=true): all, in_progress, completed, failed, overdue.
                          Present on both feeds.
                        additionalProperties:
                          type: integer
                        example:
                          active: 7
                          under_review: 0
                          completed: 23
                  total_count:
                    type: integer
  "/rfp_desk/responses/{id}":
    get:
      tags:
      - RFP Desk
      summary: Get a response with its sections
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: only_gaps
        in: query
        description: Return only sections still needing input
        schema:
          type: boolean
      responses:
        '200':
          description: The response and its sections in order
          content:
            application/json:
              schema:
                type: object
                properties:
                  response:
                    "$ref": "#/components/schemas/RfpResponse"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/responses/{id}/sections/{section_id}":
    patch:
      tags:
      - RFP Desk
      summary: Answer a section
      description: 'Writes the section''s draft as a human answer — the same rules
        as the `resolve_section` agent tool: an approved or submitted response refuses
        (409), a draft still carrying a `[NEEDS INPUT: …]` placeholder leaves the
        gap open, a changed draft is marked as human-grounded and its review mark
        is cleared, and the routed task (if any) closes when the gap closes. `save_to_library`
        also banks the answer as a library entry (contributors only; skipped for narrative
        sections and open gaps).

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: section_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - draft
              properties:
                draft:
                  type: string
                save_to_library:
                  type: boolean
                  default: false
      responses:
        '200':
          description: The updated section
          content:
            application/json:
              schema:
                type: object
                properties:
                  section:
                    "$ref": "#/components/schemas/RfpSection"
                  remaining_gaps:
                    type: integer
                  still_needs_input:
                    type: boolean
                  saved_to_library:
                    type: boolean
                  library_note:
                    type: string
                    nullable: true
        '409':
          description: The response is approved or submitted — reopen it in RFP Desk
            first
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/responses/{id}/transition":
    post:
      tags:
      - RFP Desk
      summary: Move a response to another status
      description: 'Same transition map and approval gate as the web app. Approving,
        submitting, declining or reopening a locked response needs the responsible
        person or an app admin (403 otherwise). Approval needs every section answered
        and reviewed and every required deliverable ticked (422 with the reason otherwise).
        Declining withdraws routed questions from people''s task lists.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - draft
                  - on_hold
                  - in_review
                  - approved
                  - submitted
                  - declined
      responses:
        '200':
          description: The response after the move
          content:
            application/json:
              schema:
                type: object
                properties:
                  response:
                    "$ref": "#/components/schemas/RfpResponseSummary"
                  withdrawn_tasks:
                    type: integer
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/assessments":
    get:
      tags:
      - RFP Desk
      summary: List bid/no-bid assessments
      description: Completed rfp_fit assessments the caller may read — all of them
        for an RFP Desk app admin, otherwise the ones they ran. Filter by `recommendation`.
      security:
      - BearerAuth: []
      parameters:
      - name: recommendation
        in: query
        schema:
          type: string
          enum:
          - go
          - conditional
          - no_bid
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Number of records per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated assessments
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RfpAssessmentSummary"
                  meta:
                    type: object
                    description: Standard pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items across all pages
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 15
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Number of items per page
                        example: 10
                      has_next:
                        type: boolean
                        description: Whether there is a next page
                        example: true
                      has_prev:
                        type: boolean
                        description: Whether there is a previous page
                        example: false
                      segment_counts:
                        type: object
                        description: |-
                          Per-segment row counts for the screen's filter-tab badges (exact, not capped at per_page) so a client can label every tab from the first response without fetching each one. Each count equals the matching status_type filter's total_count by construction. Keys depend on the feed (the two screens have different tabs):
                            - Personal feed (team omitted/false): active, under_review, completed.
                            - Team feed (team=true): all, in_progress, completed, failed, overdue.
                          Present on both feeds.
                        additionalProperties:
                          type: integer
                        example:
                          active: 7
                          under_review: 0
                          completed: 23
                  total_count:
                    type: integer
  "/rfp_desk/assessments/{id}":
    get:
      tags:
      - RFP Desk
      summary: Get one assessment
      description: The verdict, weighted breakdown, extracted requirements and any
        hard gate (unmet mandatory requirement or walk-away hit). The RFP's own text
        is never returned.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The assessment
          content:
            application/json:
              schema:
                type: object
                properties:
                  assessment:
                    "$ref": "#/components/schemas/RfpAssessment"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/ai_notepad/config":
    get:
      tags:
      - AI Notepad
      summary: AI Notepad feature configuration for the caller's business
      description: |
        The tenant's notepad settings — which AI features are on, the upload
        ceiling, and creation defaults. Doubles as the namespace's health/access
        probe: a business without the app enabled gets the standard JSON 403.

        Realtime credentials are deliberately NOT served here; clients reuse
        `GET /api/v1/chat/config` for Pusher.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Feature configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  ai_notepad:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      settings:
                        type: object
                        description: Tenant feature flags and defaults. Additive over
                          time — treat unknown keys as forward compatibility.
                        additionalProperties: true
                        properties:
                          ai_enabled:
                            type: boolean
                          audio_overview_enabled:
                            type: boolean
                          meeting_chat_enabled:
                            type: boolean
                          output_formats_enabled:
                            type: boolean
                          content_revision_enabled:
                            type: boolean
                          meeting_comparison_enabled:
                            type: boolean
                          sharing_enabled:
                            type: boolean
                          automations_enabled:
                            type: boolean
                          accountability_digest_enabled:
                            type: boolean
                          calendar_stub_enabled:
                            type: boolean
                          practice_recommendations_enabled:
                            type: boolean
                          max_upload_size_mb:
                            type: integer
                          default_meeting_source:
                            type: string
                          default_template_id:
                            type: string
                            nullable: true
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/pusher/auth":
    post:
      tags:
      - AI Notepad
      summary: Authorize a Pusher subscription for a notepad channel
      description: |
        Signs a subscription to one of the two notepad channels:

          - `private-ai-notepad-meeting-{id}` — per-meeting: status, artifacts,
            transcript and audio-overview events. Requires the caller to be able
            to view that meeting.
          - `private-user-ai-notepad-{userId}-business-{businessId}` — the
            caller's own inbox channel (meeting created/updated/deleted, action
            item assigned). Requires the ids to be the caller's own.

        The response is the raw Pusher auth payload, NOT wrapped in an envelope,
        so it can be handed straight to the Pusher client.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - socket_id
              - channel_name
              properties:
                socket_id:
                  type: string
                channel_name:
                  type: string
                  description: One of the two notepad channel shapes above.
      responses:
        '200':
          description: Pusher auth payload (returned direct, un-enveloped)
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth:
                    type: string
                  channel_data:
                    type: string
                    nullable: true
        '400':
          "$ref": "#/components/responses/NotepadError"
        '403':
          description: Not a channel this caller may subscribe to
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/preferences":
    get:
      tags:
      - AI Notepad
      summary: The caller's own notepad preferences
      description: |
        EFFECTIVE values — the user's stored preferences resolved against the
        tenant policy — plus a `policy` block describing the ceiling the admin
        set. `policy.admin_owned` names the keys a user cannot override.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Effective preferences and the tenant policy
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadPreferences"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
    patch:
      tags:
      - AI Notepad
      summary: Update the caller's own notepad preferences
      description: |
        Stores ONLY the keys present in the request; omitted keys are left
        alone rather than reset. An admin-owned key is refused rather than
        silently ignored. Responds with the same effective shape as GET.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                meeting_detection_enabled:
                  type: boolean
                auto_capture:
                  type: string
                default_notebook_id:
                  type: integer
                  nullable: true
                default_language:
                  type: string
                  nullable: true
                retain_audio_days:
                  type: integer
                notify_on_ready:
                  type: boolean
                  description: The "your meeting notes are ready" inbox message.
                default_template_id:
                  type: integer
                  nullable: true
                capture_consent_granted:
                  type: boolean
                meeting_reminders_enabled:
                  type: boolean
                meeting_reminder_lead_minutes:
                  type: integer
      responses:
        '200':
          description: Updated effective preferences
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadPreferences"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings":
    get:
      tags:
      - AI Notepad
      summary: List the caller's meetings and notes
      description: |
        Everything the caller can view, newest first. Each row carries
        denormalized action-item counts and an `artifacts_status` so a list
        render needs no per-row fan-out.

        **Two paging modes, and they are exclusive.** Without `sort` the list is
        keyset-paginated on `cursor`; WITH `sort` it switches to offset paging on
        `page`, because a keyset cursor over a non-id ordering skips and
        duplicates rows. `meta.pagination.mode` says which mode answered and
        `meta.pagination.ignored` names the paging param that was discarded, so
        a client never loops on page 1 in silence.

        `view` chooses the LIST (live notes, or Recently Deleted); `filter` is
        the chip row stacked on top of it. Deleted rows are excluded unless
        `view=trash`.

        Every vocabulary param (`view`, `filter`, `status`, `sort`) REFUSES an
        unrecognized value with 422 and the offending value echoed — it is never
        answered with a list computed under a different question. The one
        exception is `status=capturing`, which is in the client vocabulary but
        has no server state yet, so it legitimately returns an empty list.

        `meta.filters` echoes the APPLIED narrowing, so a client that reuses a
        stale `cursor` under changed filters can tell.
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: cursor
        in: query
        description: The `meta.next_cursor` from the previous page. Keyset mode only
          — discarded (and reported in `meta.pagination.ignored`) when `sort` is sent.
        schema:
          type: integer
      - name: page
        in: query
        description: 1-based offset page. Offset mode only — discarded (and reported
          in `meta.pagination.ignored`) when `sort` is absent.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: sort
        in: query
        description: Switches the endpoint from keyset to offset paging. Absent means
          the default id-descending keyset order.
        schema:
          type: string
          enum:
          - edited
          - created
          - title
      - name: view
        in: query
        description: Which list to read. `all` is the default and a legitimate no-op;
          `trash` is Recently Deleted. The Meetings / Notes / Shared narrowings are
          `filter`, not spellings of this param.
        schema:
          type: string
          enum:
          - all
          - trash
          default: all
      - name: filter
        in: query
        description: 'The chip row — what a note IS, as opposed to `status`, which
          is what has happened to it. `deleted` is deliberately absent: Recently Deleted
          is `view=trash`.'
        schema:
          type: string
          enum:
          - all
          - meetings
          - notes
          - shared
      - name: status
        in: query
        description: Client-vocabulary status filter. `capturing` is accepted and
          returns an empty list — no row this server can serialize will ever read
          it.
        schema:
          type: string
          enum:
          - processing
          - completed
          - failed
          - capturing
      - name: notebook_id
        in: query
        description: Must be a notebook the caller can view; a deleted, foreign or
          never-existed id is a 422, not an empty list.
        schema:
          type: integer
      - name: q
        in: query
        description: Case-insensitive substring search across the meeting title, AI
          summary, transcript and the user's own notes — the same four columns the
          web search page reads, so the desktop client and the browser answer the
          same question. No minimum query length.
        schema:
          type: string
      responses:
        '200':
          description: Paginated meeting list
          content:
            application/json:
              schema:
                type: object
                properties:
                  meetings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadMeetingSummary"
                  meta:
                    "$ref": "#/components/schemas/NotepadListMeta"
                  page:
                    type: integer
                    description: Offset mode only (`sort` sent) — the shipped root-level
                      twin of `meta.page`. Absent in keyset mode.
                  has_more:
                    type: boolean
                    description: Offset mode only — the shipped root-level twin of
                      `meta.has_more`. Absent in keyset mode.
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          description: An unrecognized `view` / `filter` / `status` / `sort` value,
            or a `notebook_id` the caller cannot browse. `error.code` is one of `unsupported_view`,
            `unsupported_filter`, `unsupported_status`, `unsupported_sort`, `notebook_not_found`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
    post:
      tags:
      - AI Notepad
      summary: Create a meeting or a note
      description: |
        `source` takes the CLIENT vocabulary. `paste` and `quick_note` carry
        their content inline and enqueue the AI pipeline immediately; `upload`
        creates the row first and the recording is attached by
        `POST /meetings/{id}/audio`; `system_audio` / `voice_note` are the
        live-capture sources.

        Honours `Idempotency-Key`, so a retried create returns the original
        meeting rather than a duplicate.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - source
              properties:
                source:
                  type: string
                  enum:
                  - system_audio
                  - voice_note
                  - upload
                  - paste
                  - quick_note
                title:
                  type: string
                text:
                  type: string
                  description: Body for `paste` / `quick_note`.
                note:
                  type: string
                  description: Extra user notes carried alongside a pasted transcript.
                notebook_id:
                  type: integer
                  nullable: true
                template_id:
                  type: integer
                  nullable: true
                occurred_at:
                  type: string
                  format: date-time
                  description: Defaults to now when ABSENT. A value that is present
                    but unparseable or outside the supported year range is a 422,
                    never a silent substitution of now.
                language:
                  type: string
                  nullable: true
                duration_sec:
                  type: integer
                  description: Client-measured capture length in whole seconds, for
                    the desktop dictation flow where there is no audio for the pipeline
                    to measure. Non-positive values are ignored.
                calendar_event_id:
                  type: string
                  nullable: true
                  description: The occurrence this note is being taken for, as handed
                    back by `GET /meetings/upcoming`. Send it with `calendar_provider`
                    so the note the desktop makes and the note the web makes for one
                    meeting are the same row.
                calendar_provider:
                  type: string
                  nullable: true
      responses:
        '200':
          description: A note for this `calendar_event_id` already existed, so nothing
            was created and the existing one is handed back. Same body as the 201;
            `processing.reason` is `already_exists`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadCreateResponse"
        '201':
          description: The created meeting
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadCreateResponse"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/upcoming":
    get:
      tags:
      - AI Notepad
      summary: Calendar meetings starting soon
      description: |
        The pre-meeting feed the desktop client raises its "your meeting starts
        in N minutes — start AI Notepad?" prompt from. Same resolver the web and
        `/m/` prompts poll, so the surfaces cannot disagree.

        `meta.enabled` reflects the tenant's calendar-stub setting;
        `meta.next_poll_in_seconds` is the server telling the client how long to
        wait before asking again.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Upcoming calendar meetings
          content:
            application/json:
              schema:
                type: object
                properties:
                  meetings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadUpcomingMeeting"
                  meta:
                    type: object
                    description: 'One key set in both branches, with an explicit null
                      or zero where a value does not apply — a caller with the prompt
                      switched off is answered `enabled: false` and a 200, never a
                      403.'
                    properties:
                      enabled:
                        type: boolean
                      lead_minutes:
                        type: integer
                        nullable: true
                      next_poll_in_seconds:
                        type: integer
                        nullable: true
                        description: How long to wait before asking again. Null means
                          stand down.
                      count:
                        type: integer
                        description: Rows returned in this response.
                      max_results:
                        type: integer
                        description: 'The server-side bound on the feed. There is
                          no cursor: this is a rolling window the client re-polls,
                          not a list it pages through.'
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/meetings/{id}":
    get:
      tags:
      - AI Notepad
      summary: Meeting detail
      description: |
        The list shape plus everything the detail pane needs: the note body in
        both formats, capability flags, a signed expiring recording URL, the
        audio-overview state, the source document's web URL, and the public
        share-link state.

        `share_link` is always present and is `null` until a public link exists.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Meeting detail
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMeetingDetail"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
    delete:
      tags:
      - AI Notepad
      summary: Delete a meeting
      description: |
        Moves the meeting into Recently Deleted, recoverable for 30 days by
        `POST /meetings/{id}/restore`; a scheduled sweep purges it after that.
        Only the creator or an `owner` collaborator may delete — shared editors
        and viewers cannot.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Deleted. `deleted` is true and `deleted_at` starts the purge
            countdown.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          description: The caller is not the creator or an `owner` collaborator, or
            the note is already in Recently Deleted (`meeting_deleted`) — re-deleting
            would restart the 30-day purge clock on a note the user is trying to recover.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The row vanished between the load and the write (`meeting_delete_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/restore":
    post:
      tags:
      - AI Notepad
      summary: Restore a meeting from Recently Deleted
      description: Takes the same permission as deleting — it is the inverse of it,
        not a lesser action. Answers the shared terse ack with `deleted_at` back to
        null, so a client merging it into its cached row drops the purge countdown
        and knows where the restored note sorts.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Restored, with `deleted` back to false
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The note is not in Recently Deleted (`meeting_not_deleted`),
            or the row vanished under the request (`meeting_restore_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/pin":
    post:
      tags:
      - AI Notepad
      summary: Pin a meeting to the top of the list
      description: Pinned rows sort above the chosen sort order rather than instead
        of it.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Pin state, in the shared terse-ack shape
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          description: Only the note's owner may pin it, or the note is in Recently
            Deleted (`meeting_deleted`) — pinning writes the column every viewer's
            list is ordered by.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The row vanished between the load and the write (`meeting_pin_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
    delete:
      tags:
      - AI Notepad
      summary: Unpin a meeting
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Pin state, with `pinned_at` cleared
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          description: Only the note's owner may unpin it, or the note is in Recently
            Deleted (`meeting_deleted`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The row vanished between the load and the write (`meeting_unpin_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/notebook":
    patch:
      tags:
      - AI Notepad
      summary: File a meeting into a notebook, or take it out
      description: |
        A blank / null `notebook_id` means "take it out of its notebook". A
        narrow member action rather than a broad meeting PATCH, so that title
        and visibility cannot be silently accepted and dropped.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notebook_id:
                  type: integer
                  nullable: true
                  description: Null or blank removes the meeting from its notebook.
      responses:
        '200':
          description: The updated meeting
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingEntity"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/{id}/notes":
    patch:
      tags:
      - AI Notepad
      summary: Replace the note body (the editor's autosave target)
      description: |
        Called repeatedly on a debounce, so it is cheap and idempotent.

        Send `notes_html`. It is the canonical body, and it WINS whenever the
        key is present — so a client sending rich text alongside a stale
        markdown copy of it keeps the rich text.

        `notes` (and its `user_notes_markdown` alias) are DEPRECATED and
        accepted only until the pre-flip desktop build has rolled out. Markdown
        was retired as a write format because a client round-tripping a body
        through it silently flattens tables, highlights and images; a client
        that only ever had markdown is not round-tripping anything, which is why
        the fallback is safe in the meantime. Do not write new clients against
        it.

        A request carrying no body key at all answers 422 `notes_required`, and
        so does an explicitly null `notes_html` — a dropped or lost key must
        never be read as "erase the note". An explicit `""` clears the body,
        which is a real edit.

        The response echoes BOTH formats: the server derives whichever one you
        did not send, and the markdown copy is what the AI pipeline and search
        read.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: One body key is required. `notes_html` for every current
                client; the markdown pair is the deprecated pre-flip shape.
              properties:
                notes_html:
                  type: string
                  description: The rich-text body. `""` clears it. Wins over the pair
                    below.
                notes:
                  type: string
                  deprecated: true
                  description: Markdown body, pre-flip clients only. The server derives
                    the HTML from it.
                user_notes_markdown:
                  type: string
                  deprecated: true
                  description: Alias of `notes`.
              anyOf:
              - required:
                - notes_html
              - required:
                - notes
              - required:
                - user_notes_markdown
      responses:
        '200':
          description: The saved body in both formats
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  notes:
                    type: string
                    nullable: true
                    description: Server-derived markdown twin.
                  notes_html:
                    type: string
                    nullable: true
                  status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The server may have flipped this — writing to a failed
                      note resets it.
                  saved_at:
                    type: string
                    format: date-time
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: |
            `not_a_note` (only a note's body is directly editable — use the
            section editor for AI output), `notes_required`, or
            `content_too_long`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/transcript":
    get:
      tags:
      - AI Notepad
      summary: The meeting transcript, in ordered segments
      description: |
        `ready: false` with an empty `segments` array is a normal "not
        transcribed yet" answer, NOT an error — the client waits for the
        realtime `transcript:final` event.

        Segments are derived from the stored transcript at request time, so
        `start_ms`, `end_ms` and `confidence` are currently always null; there is
        no per-segment timing yet.

        `status` here is the MEETING vocabulary while the sibling artifacts read
        puts the ARTIFACTS vocabulary under that same key, and the two COLLIDE on
        `failed`. `meeting_status` and `artifacts_status` are the unambiguous
        pair, present on all three of this meeting's reads and always meaning the
        same thing — prefer them.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - name: limit
        in: query
        schema:
          type: integer
          default: 200
          minimum: 1
          maximum: 500
      - name: cursor
        in: query
        description: The `meta.next_cursor` (a segment `seq`) from the previous page.
        schema:
          type: integer
      - name: full
        in: query
        description: Return every segment in one response, ignoring `limit`.
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Transcript segments (possibly not ready yet)
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The MEETING vocabulary. Ambiguous by name across
                      this meeting's three reads — see `meeting_status`.
                  meeting_status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The meeting's own lifecycle, under a name that cannot
                      be confused with the artifacts one.
                  artifacts_status:
                    type: string
                    enum:
                    - pending
                    - ready
                    - failed
                    description: Whether the AI output exists yet. `pending` means
                      show a skeleton, not an error.
                  ready:
                    type: boolean
                    description: False until a transcript exists.
                  speakers:
                    type: array
                    description: The speaker roster. Every element carries an `id`
                      (`s1`, `s2`, …) that the segments' `speaker` resolves against
                      — derived from the element's position when the stored roster
                      has none.
                    items:
                      type: object
                      additionalProperties: true
                  segments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadTranscriptSegment"
                  meta:
                    type: object
                    properties:
                      full:
                        type: boolean
                      limit:
                        type: integer
                        description: Page SIZE, in both modes.
                      count:
                        type: integer
                        description: Segments actually returned.
                      has_more:
                        type: boolean
                      next_cursor:
                        type: integer
                        nullable: true
                        description: Always null in `full` mode, which discards the
                          cursor — read the rest with the paged mode, which honours
                          it and terminates.
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/artifacts":
    get:
      tags:
      - AI Notepad
      summary: The AI output — summary and insight sections
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Artifacts, with a status the client renders skeletons from
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadArtifacts"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/regenerate":
    post:
      tags:
      - AI Notepad
      summary: Re-run the AI pipeline for a meeting's artifacts
      description: Returns 202 with the meeting back in `processing`; completion arrives
        via the realtime `artifacts:ready` event.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                target:
                  type: string
                  enum:
                  - summary
                  - insights
                  - action_items
                  - all
                  default: all
      responses:
        '202':
          description: Regeneration enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/convert":
    post:
      tags:
      - AI Notepad
      summary: Convert a plain note into a meeting note
      description: Runs the note's own text through the AI pipeline. Refused for an
        empty note — its text is what a meeting note is summarized from.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '202':
          description: Conversion enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingEntity"
                  truncated_fields:
                    type: array
                    description: Which body fields the server's length ceiling cut
                      — the same key and meaning the create carries. Always an array
                      so the key set is stable; empty for `mode=notes`, which pastes
                      nothing.
                    items:
                      type: string
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/transcribe_text":
    post:
      tags:
      - AI Notepad
      summary: Submit or replace a pasted transcript
      description: The text path — no ASR. Only valid for a `paste`-sourced meeting;
        a quick note or a recording-backed meeting is refused 422.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              properties:
                text:
                  type: string
      responses:
        '202':
          description: Pipeline enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
                  processing:
                    type: object
                    description: Whether the pipeline was actually enqueued — the
                      same shape the create answers with, so a client can stop rendering
                      a progress state for a job that was never started.
                    properties:
                      started:
                        type: boolean
                      reason:
                        type: string
                  truncated_fields:
                    type: array
                    description: Which body fields the server's length ceiling cut.
                      Always an array; empty is the normal case.
                    items:
                      type: string
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/audio":
    post:
      tags:
      - AI Notepad
      summary: Attach a recording (direct multipart upload)
      description: |
        The small-file transport. Enforces the tenant's size ceiling and the
        allowed formats, then enqueues the AI pipeline. Use the resumable
        `audio/init` + `audio/complete` pair for large files.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '202':
          description: Attached and pipeline enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
        '413':
          description: Larger than the tenant's upload ceiling
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '415':
          description: Unsupported audio format
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/audio/init":
    post:
      tags:
      - AI Notepad
      summary: Begin a resumable recording upload
      description: Returns a direct-upload target the client PUTs the file to, then
        finalizes with `audio/complete`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - filename
              - byte_size
              - content_type
              properties:
                filename:
                  type: string
                byte_size:
                  type: integer
                content_type:
                  type: string
                checksum:
                  type: string
      responses:
        '200':
          description: Upload target
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  upload:
                    type: object
                    properties:
                      signed_blob_id:
                        type: string
                      url:
                        type: string
                      byte_size:
                        type: integer
                      headers:
                        type: object
                        additionalProperties:
                          type: string
        '413':
          description: Larger than the tenant's upload ceiling
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '415':
          description: Unsupported audio format
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/audio/complete":
    post:
      tags:
      - AI Notepad
      summary: Finalize a resumable recording upload
      description: Attaches the uploaded blob to the meeting and enqueues the AI pipeline.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - signed_blob_id
              properties:
                signed_blob_id:
                  type: string
                  description: From the `audio/init` response.
      responses:
        '202':
          description: Attached and pipeline enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/images":
    post:
      tags:
      - AI Notepad
      summary: Upload an image for the note body
      description: |
        Quill inlines a pasted or chosen image as a base64 data URI, which pushes
        a note past the body length cap and stops it saving at all. This uploads
        instead and returns a URL to embed.

        The URL is permanent, absolute and bearer-readable by design: it is
        written INTO the stored note body, an `<img>` tag cannot send an
        Authorization header, and the same body is rendered by both the web app
        and the desktop client (whose renderer origin is not the tenant host, so
        a path-only URL would never load there). `url` may be null if the URL
        could not be built — nothing is embedded in that case.

        The file is stored as a platform `MediaItem` subject-scoped to the
        meeting, so type and size are enforced by the shared upload gate.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201':
          description: The uploaded image
          content:
            application/json:
              schema:
                type: object
                properties:
                  image:
                    type: object
                    properties:
                      url:
                        type: string
                        nullable: true
                        description: Absolute, permanent, embeddable. Null when the
                          URL could not be built.
                      byte_size:
                        type: integer
                        nullable: true
                      content_type:
                        type: string
                        nullable: true
        '403':
          description: Caller cannot edit this note
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          description: "`file_required` or `invalid_image`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/section":
    patch:
      tags:
      - AI Notepad
      summary: Replace one AI section's content
      description: |
        The section editor. `section` is the SERVER vocabulary — note
        `insights`, not the client's `key_insights` tab name.

        Responds with the whole artifacts body so the client can re-render the
        section without a refetch.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - section
              - content
              properties:
                section:
                  "$ref": "#/components/schemas/NotepadSectionName"
                content:
                  description: A string for `summary`; a structured list for `action_items`
                    / `insights`.
                  oneOf:
                  - type: string
                  - type: array
                    items:
                      type: object
                      additionalProperties: true
      responses:
        '200':
          description: The saved section, with refreshed artifacts
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSectionSaveResponse"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/section/revise":
    post:
      tags:
      - AI Notepad
      summary: AI-revise one section
      description: Rewrites, expands, refines or simplifies the section's current
        content and saves the result. Responds with the same body as a section save.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - section
              - action
              properties:
                section:
                  "$ref": "#/components/schemas/NotepadSectionName"
                action:
                  type: string
                  enum:
                  - rewrite
                  - expand
                  - refine
                  - simplify
                instruction:
                  type: string
                  description: Optional free-text steer for the revision.
      responses:
        '200':
          description: The revised section
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSectionSaveResponse"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/section/versions":
    get:
      tags:
      - AI Notepad
      summary: Edit history for one AI section
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - name: section
        in: query
        required: true
        schema:
          "$ref": "#/components/schemas/NotepadSectionName"
      responses:
        '200':
          description: Versions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  section:
                    "$ref": "#/components/schemas/NotepadSectionName"
                  versions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadSectionVersion"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/section/restore":
    post:
      tags:
      - AI Notepad
      summary: Roll a section back to an earlier version
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - section
              - version_id
              properties:
                section:
                  "$ref": "#/components/schemas/NotepadSectionName"
                version_id:
                  type: integer
      responses:
        '200':
          description: The restored section
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSectionSaveResponse"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/action_items":
    get:
      tags:
      - AI Notepad
      summary: A meeting's action items
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - name: limit
        in: query
        description: Defaults to the maximum, which is what shipped clients get.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Action items with assignee identity and due labels
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The MEETING vocabulary. Ambiguous by name across
                      this meeting's three reads — see `meeting_status`.
                  meeting_status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The meeting's own lifecycle, unambiguously named.
                  artifacts_status:
                    type: string
                    enum:
                    - pending
                    - ready
                    - failed
                    description: Whether the AI output exists yet.
                  action_items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadActionItem"
                  meta:
                    type: object
                    description: Counts are computed IN THE DB, never off the returned
                      page, so a capped page never makes the tab's totals lie.
                    properties:
                      open_count:
                        type: integer
                      total_count:
                        type: integer
                      limit:
                        type: integer
                      has_more:
                        type: boolean
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
    post:
      tags:
      - AI Notepad
      summary: Add an action item to a meeting
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                assignee_user_id:
                  type: integer
                  nullable: true
                due_date:
                  type: string
                  format: date
                  nullable: true
                commitment_type:
                  type: string
                  enum:
                  - hard
                  - soft
      responses:
        '201':
          description: The created action item
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_item:
                    "$ref": "#/components/schemas/NotepadActionItem"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/action_items":
    get:
      tags:
      - AI Notepad
      summary: Action items assigned to the caller, across meetings
      description: The "My Action Items" view. Each row carries a lightweight meeting
        reference so the client can deep-link back to its source.
      security:
      - BearerAuth: []
      parameters:
      - name: assignee
        in: query
        description: Only `me` is supported.
        schema:
          type: string
          enum:
          - me
          default: me
      - name: limit
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: cursor
        in: query
        schema:
          type: integer
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
          - completed
      responses:
        '200':
          description: Paginated cross-meeting action items
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_items:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/NotepadActionItem"
                      - type: object
                        properties:
                          meeting:
                            "$ref": "#/components/schemas/NotepadMeetingRef"
                  meta:
                    "$ref": "#/components/schemas/NotepadCursorMeta"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/action_items/{id}":
    patch:
      tags:
      - AI Notepad
      summary: Update an action item
      description: Edits the title, assignee, due date or commitment type, and toggles
        `done` (which maps onto the server's pending/completed status). An assignee
        change emits the realtime `action_item:assigned` event.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                done:
                  type: boolean
                assignee_user_id:
                  type: integer
                  nullable: true
                due_date:
                  type: string
                  format: date
                  nullable: true
                commitment_type:
                  type: string
                  enum:
                  - hard
                  - soft
      responses:
        '200':
          description: The updated action item
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_item:
                    "$ref": "#/components/schemas/NotepadActionItem"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    put:
      tags:
      - AI Notepad
      summary: Update an action item (PUT alias)
      description: Alias of PATCH on this path — Rails' `resources` answers both verbs
        identically. Documented because it is callable, not because it is preferred;
        new clients should use PATCH.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                done:
                  type: boolean
                assignee_user_id:
                  type: integer
                  nullable: true
                due_date:
                  type: string
                  format: date
                  nullable: true
                commitment_type:
                  type: string
                  enum:
                  - hard
                  - soft
      responses:
        '200':
          description: The updated action item
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_item:
                    "$ref": "#/components/schemas/NotepadActionItem"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    delete:
      tags:
      - AI Notepad
      summary: Delete an action item
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  deleted:
                    type: boolean
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{meeting_id}/members":
    get:
      tags:
      - AI Notepad
      summary: Who a meeting is shared with
      description: The creator appears as an immutable synthetic `owner` member. People
        search for the add flow reuses `GET /api/v1/chat/users/search`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      responses:
        '200':
          description: Members
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadMember"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
    post:
      tags:
      - AI Notepad
      summary: Share a meeting with a teammate
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                role:
                  type: string
                  enum:
                  - viewer
                  - editor
                  default: viewer
      responses:
        '201':
          description: The added member
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/NotepadMember"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/{meeting_id}/members/{id}":
    patch:
      tags:
      - AI Notepad
      summary: Change a member's role
      description: "`id` is the member's USER id. The owner's role is immutable."
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      - name: id
        in: path
        required: true
        description: The member's user id.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - role
              properties:
                role:
                  type: string
                  enum:
                  - viewer
                  - editor
      responses:
        '200':
          description: The updated member
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/NotepadMember"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    put:
      tags:
      - AI Notepad
      summary: Change a member's role (PUT alias)
      description: Alias of PATCH on this path — Rails' `resources` answers both verbs
        identically. Documented because it is callable, not because it is preferred;
        new clients should use PATCH.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - role
              properties:
                role:
                  type: string
                  enum:
                  - viewer
                  - editor
      responses:
        '200':
          description: The updated member
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/NotepadMember"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    delete:
      tags:
      - AI Notepad
      summary: Remove a member's access
      description: "`id` is the member's USER id. The owner cannot be removed."
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      - name: id
        in: path
        required: true
        description: The member's user id.
        schema:
          type: integer
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_id:
                    type: integer
                  removed:
                    type: boolean
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/audio_overview":
    get:
      tags:
      - AI Notepad
      summary: The narrated audio overview
      description: "`available: false` is the normal not-generated-yet answer. When
        available, `url` is signed and expiring — refetch rather than storing it."
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Audio overview state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadAudioOverviewDetail"
        '403':
          description: The audio-overview feature is off for this business
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '502':
          description: The overview exists but storage could not mint a URL for it
            (`audio_overview_url_unavailable`). A named, retryable failure rather
            than a 200 carrying a `url` the player will choke on.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
    post:
      tags:
      - AI Notepad
      summary: Generate the audio overview
      description: Returns 202; the client awaits the realtime `audio_overview:ready`
        event, then GETs the signed URL. Requires editor-level membership.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '202':
          description: Generation enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  status:
                    type: string
                    description: Always `generating`. This is the GENERATION's state,
                      not the meeting's — see `meeting_status` for that.
                    enum:
                    - generating
                  state:
                    type: string
                    description: The SAME key the two reads carry, so a client that
                      fires this POST has something to poll on. `generating` here;
                      `ready` / `failed` / `none` on a read.
                    enum:
                    - generating
                  processing:
                    type: object
                    description: Whether THIS request started anything. A generation
                      already in flight for this meeting holds the concurrency key,
                      so the enqueue aborts and nothing new was started — the overview
                      is still being generated, just not by this call.
                    properties:
                      started:
                        type: boolean
                      reason:
                        type: string
                        enum:
                        - queued
                        - already_generating
                  meeting_status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The meeting's own lifecycle, unambiguously named.
        '403':
          description: Feature off, or caller lacks editor access
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/chat_history":
    get:
      tags:
      - AI Notepad
      summary: The persisted AI-chat conversation for a meeting
      description: Lets a client re-hydrate the chat tab on reopen instead of starting
        blank. The streamed POST persists both turns.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Chat messages, chronological
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadChatMessage"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/chat":
    post:
      tags:
      - AI Notepad
      summary: Ask a question about one meeting (streamed)
      description: |
        **Server-Sent Events, not JSON.** The response is `text/event-stream`
        carrying incremental `delta` frames, a terminal `complete` frame (message
        id, model, usage) and an `error` frame if generation fails mid-stream.
        Answers are grounded in the meeting and carry citations back to
        transcript segments.

        The answer is generated server-side with the server-held model key — no
        model credential ever reaches a client. A short-lived `chat_stream`
        capture token from `POST /captures/token` is re-checked when presented.

        Both turns are persisted; read them back with `chat_history`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                capture_token:
                  type: string
                  description: Optional scoped token from `POST /captures/token`.
      responses:
        '200':
          description: An SSE stream of delta / complete / error frames
          content:
            text/event-stream:
              schema:
                type: string
                description: Newline-delimited SSE frames. Not JSON — do not parse
                  the body as a document.
        '403':
          description: Meeting chat is off for this business, or access denied
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/chat":
    post:
      tags:
      - AI Notepad
      summary: Ask a question across your notes (streamed)
      description: |
        **Server-Sent Events, not JSON** — same transport as the per-meeting
        chat. `source_scope` chooses which of the caller's visible meetings
        ground the answer.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                source_scope:
                  type: string
                  enum:
                  - all
                  - notebook
                  - selected
                  - date_range
                  default: all
                notebook_id:
                  type: integer
                  nullable: true
                meeting_ids:
                  type: array
                  items:
                    type: integer
                from:
                  type: string
                  format: date
                to:
                  type: string
                  format: date
      responses:
        '200':
          description: An SSE stream of delta / complete / error frames
          content:
            text/event-stream:
              schema:
                type: string
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/captures/token":
    post:
      tags:
      - AI Notepad
      summary: Mint a short-lived scoped capture token
      description: |
        A stateless, signed grant bound to ONE meeting and one scope, with a
        5-minute ceiling. It exists so a client never holds a model API key:
        it presents this to the streaming surfaces instead. Refresh by calling
        again.

        Only `chat_stream` is issuable today; an unknown scope is a 422 rather
        than a silently-issued token.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - scope
              - meeting_id
              properties:
                scope:
                  type: string
                  enum:
                  - chat_stream
                meeting_id:
                  type: integer
      responses:
        '201':
          description: The minted token
          content:
            application/json:
              schema:
                type: object
                properties:
                  capture_token:
                    type: object
                    properties:
                      token:
                        type: string
                      scope:
                        type: string
                        enum:
                        - chat_stream
                      meeting_id:
                        type: integer
                      expires_at:
                        type: string
                        format: date-time
                      expires_in:
                        type: integer
                        description: Seconds. At most 300.
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: Unknown or unsupported scope
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/send_to_chat":
    post:
      tags:
      - AI Notepad
      summary: Post a meeting's sections into a chat room
      description: |
        Composes the chosen sections as Markdown and posts them into a Shifts
        chat room the caller belongs to, through the same path the chat surface
        uses.

        The ONE endpoint in this namespace that requires a token scope —
        `write:chat` — because it writes a chat message. Also re-checks that the
        Chat app is enabled and visible to the caller.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - room_id
              - sections
              properties:
                room_id:
                  type: integer
                sections:
                  type: array
                  items:
                    type: string
                    enum:
                    - summary
                    - key_insights
                    - action_items
                client_uuid:
                  type: string
                  description: Client-side dedup key for the posted message.
      responses:
        '201':
          description: Posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                    nullable: true
                  room_id:
                    type: integer
        '403':
          description: |
            `forbidden` (missing write:chat), `app_not_enabled`,
            `app_not_accessible`, or `im_not_allowed`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          description: "`room_not_found`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/notebooks":
    get:
      tags:
      - AI Notepad
      summary: Notebooks the caller can file notes into
      description: Every row is a `notebook_id` both write paths accept — created
        by the caller, or shared with them as editor/owner.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Notebooks
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebooks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadNotebook"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
    post:
      tags:
      - AI Notepad
      summary: Create a notebook
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '201':
          description: The created notebook
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebook:
                    "$ref": "#/components/schemas/NotepadNotebook"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/notebooks/{id}":
    patch:
      tags:
      - AI Notepad
      summary: Rename or re-describe a notebook
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '200':
          description: The updated notebook
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebook:
                    "$ref": "#/components/schemas/NotepadNotebook"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    put:
      tags:
      - AI Notepad
      summary: Rename or re-describe a notebook (PUT alias)
      description: Alias of PATCH on this path — Rails' `resources` answers both verbs
        identically. Documented because it is callable, not because it is preferred;
        new clients should use PATCH.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '200':
          description: The updated notebook
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebook:
                    "$ref": "#/components/schemas/NotepadNotebook"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    delete:
      tags:
      - AI Notepad
      summary: Delete a notebook
      description: The notes inside are not deleted — they are unfiled.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  deleted:
                    type: boolean
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: API token authentication
    ServiceAccountAuth:
      type: apiKey
      in: header
      name: X-API-Secret
      description: Service account secret (used with Bearer token)
    InternalAPI:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Internal API authentication with X-Internal-API header
    ChangelogSecret:
      type: apiKey
      in: header
      name: X-Changelog-Secret
      description: Changelog-specific secret for internal operations
  parameters:
    Page:
      name: page
      in: query
      description: Page number for pagination
      schema:
        type: integer
        default: 1
        minimum: 1
    PerPage:
      name: per_page
      description: Number of items per page
      in: query
      schema:
        type: integer
        default: 25
        minimum: 1
        maximum: 100
  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    UnauthorizedError:
      description: Authentication required or token invalid
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    ForbiddenError:
      description: Insufficient permissions to access this resource
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NotFoundError:
      description: Resource not found
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    ValidationError:
      description: Validation error
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/ValidationErrors"
    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    RateLimited:
      description: Too many requests - rate limit exceeded
      headers:
        Retry-After:
          description: Seconds until rate limit reset
          schema:
            type: integer
        X-RateLimit-Limit:
          description: Total requests allowed per window
          schema:
            type: integer
        X-RateLimit-Remaining:
          description: Requests remaining in current window
          schema:
            type: integer
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
  schemas:
    BusinessDiscoveryResult:
      type: object
      description: |
        Tenants the submitted email address can sign in to. Always returned with
        a 200, including when the address is unknown or has no active tenants —
        in both of those cases `businesses` is an empty array and `total_count`
        is 0. The endpoint is unauthenticated, so it deliberately does NOT
        distinguish "no such account" from "account with no active tenants":
        doing so would make it an account-existence oracle.
      properties:
        businesses:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 456
              name:
                type: string
                example: Office Chat Solutions
              subdomain:
                type: string
                example: officechat
              logo_url:
                type: string
                nullable: true
                description: |
                  Absolute logo URL, or null when the tenant has no logo attached.
                  Served from the tenant's OWN host (matching api_base_url), not
                  the host that answered this request.
                example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/logo.png
              api_base_url:
                type: string
                description: |
                  Origin the client should send the subsequent login request to.
                  Already carries the environment suffix (-dev / -qa / -staging;
                  none in production), so clients must not rebuild the host from
                  `subdomain` themselves.
                example: https://officechat.workforce.mangoapps.com
        total_count:
          type: integer
          example: 2
    SupportTicketSummary:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
        status_color_class:
          type: string
          description: Bootstrap badge class mapped from status
          example: bg-warning
        priority:
          type: string
        priority_color_class:
          type: string
          description: Bootstrap badge class mapped from priority
          example: bg-danger
        escalated:
          type: boolean
        request_type:
          type: string
        requester:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
        assignee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        due_at:
          type: string
          format: date-time
          nullable: true
        is_overdue:
          type: boolean
          nullable: true
        sla_breached:
          type: boolean
          description: True when the SLA deadline (sla_due_at) has passed and the
            ticket is still open
        sla_due_at:
          type: string
          format: date-time
          nullable: true
          description: SLA deadline computed from priority and assignment time
    SupportTicketDetail:
      allOf:
      - "$ref": "#/components/schemas/SupportTicketSummary"
      - type: object
        properties:
          description:
            type: string
          resolution_notes:
            type: string
            nullable: true
          resolved_at:
            type: string
            format: date-time
            nullable: true
          closed_at:
            type: string
            format: date-time
            nullable: true
          comments_count:
            type: integer
          comments:
            type: array
            items:
              "$ref": "#/components/schemas/TicketComment"
    TicketComment:
      type: object
      properties:
        id:
          type: integer
        content:
          type: string
        author:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
        internal:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ServiceCatalogType:
      type: object
      properties:
        type:
          type: string
          description: Slug identifier for the request type
        title:
          type: string
        icon:
          type: string
          description: Font Awesome icon class
        color:
          type: string
          description: Bootstrap color name
        description:
          type: string
        examples:
          type: string
        estimated_resolution:
          type: string
        sla_hours:
          type: integer
        custom:
          type: boolean
        category:
          type: string
        priority:
          type: string
        service_department_id:
          type: integer
          nullable: true
    KbSearchResult:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        snippet:
          type: string
          description: Content excerpt (up to 500 characters)
        url:
          type: string
          description: Absolute URL to the article
        category:
          type: string
          nullable: true
        relevance_score:
          type: number
          format: float
          description: Normalized relevance in [0, 1]
        source:
          type: string
          description: Where the result came from
          example: help_article
    AskAiMessageResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        conversation_id:
          type: string
          format: uuid
          description: Unique identifier for this conversation
          example: 550e8400-e29b-41d4-a716-446655440000
        status:
          type: string
          enum:
          - processing
          - completed
          - error
          example: processing
        websocket:
          type: object
          description: WebSocket subscription details
          properties:
            channel:
              type: string
              example: AiResponseChannel
            subscription:
              type: object
              properties:
                conversation_id:
                  type: string
                  format: uuid
    AskAiConversation:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Conversation session ID
        type:
          type: string
          enum:
          - forms
          - tasks
          - epms
          - general
          example: general
        status:
          type: string
          enum:
          - active
          - completed
          - archived
          example: active
        message_count:
          type: integer
          example: 12
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
    AskAiMessage:
      type: object
      properties:
        role:
          type: string
          enum:
          - user
          - assistant
          - system
          example: assistant
        content:
          type: string
          example: You have 15 PTO days remaining for this year.
        timestamp:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
          description: Additional message metadata (intent, tool usage, etc.)
    AskAiAgentInfo:
      type: object
      properties:
        name:
          type: string
          example: Scheduling Assistant
        slug:
          type: string
          example: scheduling
        description:
          type: string
          example: Helps with shifts, schedules, and time off requests
        icon:
          type: string
          example: calendar
        examples:
          type: array
          items:
            type: string
          example:
          - What shifts do I have this week?
          - Request time off for next Friday
          - Who is working tomorrow?
    VoiceTokenResponse:
      type: object
      description: Response from voice_token endpoint with all connection details
        for voice mode
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            token:
              type: string
              description: Ephemeral token for OpenAI Realtime API (expires in 60
                seconds)
              example: eph_abc123...
            session_id:
              type: string
              format: uuid
              description: Unique session ID for this voice session
              example: 550e8400-e29b-41d4-a716-446655440000
            expires_at:
              type: string
              format: date-time
              description: When the ephemeral token expires
              example: '2025-01-26T14:21:00Z'
            session_config:
              type: object
              description: Configuration for OpenAI Realtime session
              properties:
                voice:
                  type: string
                  example: alloy
                modalities:
                  type: array
                  items:
                    type: string
                  example:
                  - text
                  - audio
                turn_detection:
                  type: object
                  properties:
                    type:
                      type: string
                      example: server_vad
                    threshold:
                      type: number
                      example: 0.5
            limit_info:
              type: object
              description: Voice usage limits and current usage
              properties:
                daily_used:
                  type: integer
                  description: Minutes used today
                  example: 5
                daily_limit:
                  type: integer
                  description: Daily limit in minutes
                  example: 30
                remaining:
                  type: integer
                  description: Remaining minutes for today
                  example: 25
                session_limit:
                  type: integer
                  description: Max minutes per session
                  example: 15
                monthly_sessions:
                  type: integer
                  description: Total voice sessions this billing period
                monthly_minutes:
                  type: number
                  description: Total minutes used this billing period
                monthly_cost_cents:
                  type: integer
                  description: Total cost in cents this billing period
            billing_info:
              type: object
              description: Billing information for voice usage
              properties:
                balance:
                  type: integer
                  description: Available balance in cents
                  example: 1000
                cost_per_minute_cents:
                  type: integer
                  description: Cost per minute in cents
                  example: 10
            connections:
              type: object
              description: Connection details for WebSocket and OpenAI
              properties:
                actioncable:
                  type: object
                  description: ActionCable WebSocket connection details
                  properties:
                    url:
                      type: string
                      description: WebSocket URL for ActionCable
                      example: wss://mycompany.workforce.mangoapps.com/cable
                    channel:
                      type: string
                      description: Channel name to subscribe to
                      example: VoiceRealtimeChannel
                    params:
                      type: object
                      description: Parameters to include in subscription
                      properties:
                        session_id:
                          type: string
                          format: uuid
                openai_realtime:
                  type: object
                  description: OpenAI Realtime API connection details
                  properties:
                    url:
                      type: string
                      description: OpenAI Realtime API base URL
                      example: https://api.openai.com/v1/realtime
                    model:
                      type: string
                      description: OpenAI model for voice
                      example: gpt-4o-realtime-preview
                    sdp_endpoint:
                      type: string
                      description: Endpoint for SDP exchange
                      example: https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview
    VoiceSessionStatus:
      type: object
      description: Current status of a voice session
      properties:
        success:
          type: boolean
          example: true
        session:
          type: object
          properties:
            id:
              type: string
              format: uuid
              description: Session ID
              example: 550e8400-e29b-41d4-a716-446655440000
            status:
              type: string
              enum:
              - active
              - completed
              - pending
              description: Current session status
              example: active
            started_at:
              type: string
              format: date-time
              description: When the session started
            duration_seconds:
              type: integer
              description: Current or total session duration in seconds
              example: 120
            queries_processed:
              type: integer
              description: Number of voice queries processed in this session
              example: 5
    VoiceSessionEndResponse:
      type: object
      description: Response when ending a voice session
      properties:
        success:
          type: boolean
          example: true
        session:
          type: object
          properties:
            id:
              type: string
              format: uuid
              description: Session ID that was ended
              example: 550e8400-e29b-41d4-a716-446655440000
            duration_minutes:
              type: number
              description: Total session duration in minutes
              example: 5
            cost_cents:
              type: integer
              description: Total cost of the session in cents
              example: 50
            billed:
              type: boolean
              description: Whether billing has been processed
              example: true
    TinyTakeCapture:
      type: object
      properties:
        id:
          type: string
          example: cap_abc123
        name:
          type: string
          example: Meeting Notes 2024-01-15
        type:
          type: string
          enum:
          - image
          - video
          example: image
        mime_type:
          type: string
          example: image/png
        size_bytes:
          type: integer
          format: int64
          example: 245760
        thumbnail_url:
          type: string
          format: uri
          nullable: true
        visibility:
          type: string
          enum:
          - private
          - shared
          - public
          example: private
        tags:
          type: array
          items:
            type: string
          example:
          - meeting
          - notes
        folder_id:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TinyTakeCaptureDetail:
      allOf:
      - "$ref": "#/components/schemas/TinyTakeCapture"
      - type: object
        properties:
          width:
            type: integer
            nullable: true
            example: 1920
          height:
            type: integer
            nullable: true
            example: 1080
          duration_seconds:
            type: number
            nullable: true
            description: Video duration (null for images)
            example: 120.5
          download_url:
            type: string
            format: uri
          share_url:
            type: string
            format: uri
            nullable: true
          share_expires_at:
            type: string
            format: date-time
            nullable: true
          has_annotations:
            type: boolean
            example: false
    TinyTakeFolder:
      type: object
      properties:
        id:
          type: string
          example: fld_abc123
        name:
          type: string
          example: Project Screenshots
        parent_id:
          type: string
          nullable: true
        capture_count:
          type: integer
          example: 12
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TinyTakeStorageInfo:
      type: object
      properties:
        used_bytes:
          type: integer
          format: int64
          example: 524288000
        total_bytes:
          type: integer
          format: int64
          example: 5368709120
        used_percentage:
          type: number
          format: float
          example: 9.77
        capture_count:
          type: integer
          example: 156
        video_count:
          type: integer
          example: 23
        image_count:
          type: integer
          example: 133
        limits:
          type: object
          properties:
            max_video_duration_seconds:
              type: integer
              nullable: true
              description: Max video recording duration (null for unlimited)
              example: 3600
            max_file_size_bytes:
              type: integer
              format: int64
              example: 2147483648
            annotations_enabled:
              type: boolean
              example: true
        status:
          type: string
          enum:
          - active
          - near_limit
          - at_limit
          example: active
    TinyTakeSettings:
      type: object
      properties:
        hotkeys:
          type: object
          properties:
            capture_screen:
              type: string
              example: Ctrl+Shift+S
            capture_region:
              type: string
              example: Ctrl+Shift+R
            capture_window:
              type: string
              example: Ctrl+Shift+W
            start_recording:
              type: string
              example: Ctrl+Shift+V
            stop_recording:
              type: string
              example: Ctrl+Shift+X
            pause_recording:
              type: string
              example: Ctrl+Shift+P
        defaults:
          type: object
          properties:
            image_format:
              type: string
              enum:
              - png
              - jpg
              - gif
              example: png
            video_format:
              type: string
              enum:
              - mp4
              - webm
              example: mp4
            video_quality:
              type: string
              enum:
              - low
              - medium
              - high
              - lossless
              example: high
            video_fps:
              type: integer
              enum:
              - 15
              - 30
              - 60
              example: 30
            audio_enabled:
              type: boolean
              example: true
            microphone_enabled:
              type: boolean
              example: false
            auto_upload:
              type: boolean
              example: true
            copy_link_after_upload:
              type: boolean
              example: true
            show_cursor:
              type: boolean
              example: true
            highlight_clicks:
              type: boolean
              example: false
    TinyTakeSettingsUpdate:
      type: object
      properties:
        hotkeys:
          type: object
          additionalProperties:
            type: string
        defaults:
          type: object
          properties:
            image_format:
              type: string
              enum:
              - png
              - jpg
              - gif
            video_format:
              type: string
              enum:
              - mp4
              - webm
            video_quality:
              type: string
              enum:
              - low
              - medium
              - high
              - lossless
            video_fps:
              type: integer
              enum:
              - 15
              - 30
              - 60
            audio_enabled:
              type: boolean
            microphone_enabled:
              type: boolean
            auto_upload:
              type: boolean
            copy_link_after_upload:
              type: boolean
            show_cursor:
              type: boolean
            highlight_clicks:
              type: boolean
    TinyTakeTag:
      type: object
      properties:
        name:
          type: string
          example: meeting
        count:
          type: integer
          description: Number of captures with this tag
          example: 42
        last_used_at:
          type: string
          format: date-time
    TinyTakeAnnotation:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum:
          - arrow
          - line
          - rectangle
          - ellipse
          - text
          - highlight
          - blur
          - freehand
          - callout
        position:
          type: object
          properties:
            x:
              type: number
            "y":
              type: number
            width:
              type: number
            height:
              type: number
            points:
              type: array
              items:
                type: object
                properties:
                  x:
                    type: number
                  "y":
                    type: number
              description: For freehand/line annotations
        style:
          type: object
          properties:
            stroke_color:
              type: string
              example: "#FF0000"
            fill_color:
              type: string
              nullable: true
            stroke_width:
              type: integer
            opacity:
              type: number
              minimum: 0
              maximum: 1
            font_size:
              type: integer
            font_family:
              type: string
        text:
          type: string
          nullable: true
          description: Text content for text/callout annotations
        created_at:
          type: string
          format: date-time
    TinyTakeAnnotationInput:
      type: object
      required:
      - type
      - position
      properties:
        id:
          type: string
          description: Optional ID to preserve existing annotation
        type:
          type: string
          enum:
          - arrow
          - line
          - rectangle
          - ellipse
          - text
          - highlight
          - blur
          - freehand
          - callout
        position:
          type: object
          required:
          - x
          - "y"
          properties:
            x:
              type: number
            "y":
              type: number
            width:
              type: number
            height:
              type: number
            points:
              type: array
              items:
                type: object
                properties:
                  x:
                    type: number
                  "y":
                    type: number
        style:
          type: object
          properties:
            stroke_color:
              type: string
            fill_color:
              type: string
            stroke_width:
              type: integer
            opacity:
              type: number
            font_size:
              type: integer
            font_family:
              type: string
        text:
          type: string
    TinyTakeUser:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string
          format: email
        avatar_url:
          type: string
          format: uri
          nullable: true
    TinyTakeShare:
      type: object
      properties:
        id:
          type: string
        type:
          type: string
          enum:
          - user
          - team
        recipient:
          oneOf:
          - "$ref": "#/components/schemas/TinyTakeUser"
          - type: object
            properties:
              id:
                type: string
              name:
                type: string
              member_count:
                type: integer
        permission:
          type: string
          enum:
          - view
          - comment
          - edit
        shared_at:
          type: string
          format: date-time
        shared_by:
          "$ref": "#/components/schemas/TinyTakeUser"
    TinyTakeComment:
      type: object
      properties:
        id:
          type: string
        content:
          type: string
        author:
          "$ref": "#/components/schemas/TinyTakeUser"
        parent_id:
          type: string
          nullable: true
        position:
          type: object
          nullable: true
          properties:
            x:
              type: number
            "y":
              type: number
            timestamp:
              type: number
        reply_count:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        is_edited:
          type: boolean
    TinyTakeVideoChapter:
      type: object
      properties:
        id:
          type: string
        timestamp:
          type: number
          description: Start time in seconds
        title:
          type: string
        description:
          type: string
          nullable: true
        thumbnail_url:
          type: string
          format: uri
          nullable: true
    TinyTakeActivity:
      type: object
      properties:
        id:
          type: string
        action:
          type: string
          enum:
          - upload
          - view
          - download
          - share
          - delete
          - edit
          - comment
          - annotate
        capture:
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            thumbnail_url:
              type: string
              format: uri
              nullable: true
        actor:
          "$ref": "#/components/schemas/TinyTakeUser"
        details:
          type: object
          additionalProperties: true
          description: Action-specific details
          example:
            shared_with:
            - user@example.com
        ip_address:
          type: string
          nullable: true
        user_agent:
          type: string
          nullable: true
        timestamp:
          type: string
          format: date-time
    MessagingPaginationMeta:
      type: object
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    MessagingParticipant:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
        avatar_url:
          type: string
          nullable: true
    MessagingRecipient:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        text:
          type: string
        email:
          type: string
        meta:
          type: string
          description: Context line (title · employee id · email).
    MessagingAttachment:
      type: object
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        url:
          type: string
          nullable: true
          description: Absolute download URL.
        preview_url:
          type: string
          nullable: true
          description: Inline URL for raster images only.
        thumbnail_url:
          type: string
          nullable: true
    MessagingMessage:
      type: object
      properties:
        id:
          type: integer
        thread_id:
          type: integer
        author_id:
          type: integer
        author:
          "$ref": "#/components/schemas/MessagingParticipant"
        body:
          type: string
        mentioned_user_ids:
          type: array
          items:
            type: integer
        edited_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/MessagingAttachment"
        voicenotes:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              audio_url:
                type: string
                nullable: true
              transcript:
                type: string
                nullable: true
              created_at:
                type: string
                format: date-time
    MessagingThread:
      type: object
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - one_on_one
          - group
        title:
          type: string
        one_on_one:
          type: boolean
        archived_at:
          type: string
          format: date-time
          nullable: true
        last_message_at:
          type: string
          format: date-time
          nullable: true
        unread_count:
          type: integer
        muted:
          type: boolean
          description: 'Whether the CALLER has this conversation muted (their own
            participant `muted_until` is in the future). Present on both the list
            and detail responses so a client can render Mute vs Unmute.

            '
        last_message_preview:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            author_id:
              type: integer
            body:
              type: string
              description: 'Excerpt of the newest message, ready to render as-is.
                An attachment-only message has a blank body, so this carries "Attachment"
                / "N attachments" instead of an empty string.

                '
            created_at:
              type: string
              format: date-time
            has_attachments:
              type: boolean
    MessagingThreadDetail:
      allOf:
      - "$ref": "#/components/schemas/MessagingThread"
      - type: object
        properties:
          participants:
            type: array
            items:
              "$ref": "#/components/schemas/MessagingParticipant"
    ChatPaginationMeta:
      type: object
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
    ChatUserSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        avatar_url:
          type: string
          nullable: true
        avatar_updated_at:
          type: integer
          nullable: true
          description: Epoch seconds — cache-busting stamp.
    ChatRoom:
      type: object
      properties:
        id:
          type: integer
        room_type:
          type: string
          enum:
          - direct
          - group
          - channel
        name:
          type: string
          nullable: true
        topic:
          type: string
          nullable: true
        last_message_at:
          type: string
          format: date-time
          nullable: true
        archived_at:
          type: string
          format: date-time
          nullable: true
        member_count:
          type: integer
        unread_count:
          type: integer
        last_read_message_id:
          type: integer
          nullable: true
        pinned_at:
          type: string
          format: date-time
          nullable: true
        is_muted:
          type: boolean
        mute_end_time:
          type: string
          format: date-time
          nullable: true
        other_user_id:
          type: integer
          nullable: true
          description: DM peer (direct rooms only).
        other_user_name:
          type: string
          nullable: true
        other_user_avatar_url:
          type: string
          nullable: true
        other_user_avatar_updated_at:
          type: integer
          nullable: true
        last_message_preview:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            body:
              type: string
              description: 'Excerpt of the newest message, ready to render as-is.
                An attachment-only message has a blank body, so this carries a description
                of the attachment instead ("Photo", "Video", "Attachment: report.pdf",
                "3 attachments") — never an empty string. Author-less: prefix with
                `user_name` yourself.

                '
            user_id:
              type: integer
            user_name:
              type: string
            created_at:
              type: string
              format: date-time
            has_attachments:
              type: boolean
        has_important_messages:
          type: boolean
        important_message_count:
          type: integer
        can_send_important_msg:
          type: boolean
        can_delete_self_message:
          type: boolean
        can_delete_other_message:
          type: boolean
        image_url:
          type: string
          nullable: true
          description: Group/channel photo.
        image_updated_at:
          type: integer
          nullable: true
    ChatAttachment:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
          description: Short-lived signed download URL.
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        media_type:
          type: string
          enum:
          - image
          - gif
          - video
          - file
        processing_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        preview_url:
          type: string
          nullable: true
          description: Inline URL for raster images only.
        thumbnail_url:
          type: string
          nullable: true
        hls_playlist_url:
          type: string
          nullable: true
          description: Video with CDN configured.
        subtitle_url:
          type: string
          nullable: true
          description: Video with subtitles ready.
    ChatReaction:
      type: object
      properties:
        id:
          type: integer
        emoji:
          type: string
        user_id:
          type: integer
    ChatMessage:
      type: object
      properties:
        id:
          type: integer
        room_id:
          type: integer
        user_id:
          type: integer
        body:
          type: string
          description: Message text, ALREADY MASKED where the caller is not entitled
            to it. When `body_masked` is true this is a fixed notice string, not the
            sender's words — render it verbatim and never treat it as content to cache,
            quote, search or preview. The mask is identical on every surface that
            hands this message to a recipient (this response, the room-list `last_message_preview`,
            the push notification, and the per-user `message:created` Pusher event
            on `private-user-inbox-<uid>-business-<bid>`), so a client that renders
            one and refetches the other never sees the text change.
        body_masked:
          type: boolean
          description: 'True when `body` is the read-receipt mask rather than the
            message text. Viewer-scoped: a read-receipt request stays masked for a
            recipient until they POST `/chat/rooms/{room_id}/messages/{id}/acknowledge`,
            which is what notifies the sender and returns the revealed body. Never
            true for the author or for a message the caller has already acknowledged.'
        parent_message_id:
          type: integer
          nullable: true
        parent:
          type: object
          nullable: true
          description: Quoted message excerpt (one-level inline quote).
          properties:
            id:
              type: integer
            body:
              type: string
              description: Excerpt (max 140 chars).
            user_id:
              type: integer
            user_name:
              type: string
            created_at:
              type: string
              format: date-time
            deleted:
              type: boolean
        created_at:
          type: string
          format: date-time
        edited_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/ChatAttachment"
        reactions:
          type: array
          items:
            "$ref": "#/components/schemas/ChatReaction"
        client_uuid:
          type: string
          nullable: true
        is_system:
          type: boolean
        ack_type:
          type: string
          nullable: true
          enum:
          - important
          - read_receipt
          -
        is_important:
          type: boolean
        is_read_receipt:
          type: boolean
        requires_ack:
          type: boolean
        is_acked:
          type: boolean
          description: Whether the CALLER has acknowledged.
        msg_ack_at:
          type: string
          format: date-time
          nullable: true
        workflow_participants_count:
          type: integer
          description: Eligible recipients who acknowledged.
        ack_eligible_count:
          type: integer
          description: Total eligible recipients (excludes the author).
    ChatMember:
      type: object
      properties:
        id:
          type: integer
          description: Membership id (use for member update/remove).
        user_id:
          type: integer
        role:
          type: string
          enum:
          - admin
          - member
        joined_at:
          type: string
          format: date-time
        muted_until:
          type: string
          format: date-time
          nullable: true
        last_read_message_id:
          type: integer
          nullable: true
        user:
          nullable: true
          allOf:
          - "$ref": "#/components/schemas/ChatUserSummary"
          - type: object
            properties:
              email:
                type: string
    ChatMedia:
      type: object
      properties:
        id:
          type: integer
        media_type:
          type: string
          enum:
          - image
          - gif
          - video
          - file
        mime_type:
          type: string
        filename:
          type: string
        byte_size:
          type: integer
        processing_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        width_px:
          type: integer
          nullable: true
        height_px:
          type: integer
          nullable: true
        url:
          type: string
          nullable: true
        thumbnail_url:
          type: string
          nullable: true
        hls_playlist_url:
          type: string
          nullable: true
    ChatSettings:
      type: object
      description: Business-level chat configuration (all keys optional on PATCH).
      properties:
        direct_messages_enabled:
          type: boolean
        group_chats_enabled:
          type: boolean
        allow_edit_chat_messages:
          type: boolean
        file_uploads_enabled:
          type: boolean
        max_attachment_size_mb:
          type: integer
          minimum: 1
          maximum: 500
        max_video_attachment_size_mb:
          type: integer
        typing_indicators:
          type: boolean
        important_messages_enabled:
          type: boolean
        read_receipt_requests_enabled:
          type: boolean
        semantic_search_enabled:
          type: boolean
        chat_video_subtitles_enabled:
          type: boolean
        working_hours_start:
          type: integer
        working_hours_end:
          type: integer
    ChatPusherConfig:
      type: object
      properties:
        key:
          type: string
          nullable: true
        cluster:
          type: string
        enabled:
          type: boolean
        user_id:
          type: integer
        business_id:
          type: integer
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Machine readable error code
            message:
              type: string
              description: Human readable error message
            details:
              type: object
              description: Additional error context
      required:
      - error
    ValidationErrors:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
                description: Field name with validation error
              message:
                type: string
                description: Validation error message
    ChangelogEntry:
      type: object
      properties:
        id:
          type: integer
          example: 1
        version:
          type: string
          example: 1.6.2
        title:
          type: string
          example: Improved Agent tools and performance
        description:
          type: string
          nullable: true
          example: Major improvements to AI Agent experience
        category:
          type: string
          enum:
          - general
          - feature
          - improvement
          - fix
          - patch
          example: feature
        priority:
          type: integer
          enum:
          - 0
          - 1
          - 2
          example: 1
          description: 0=Normal, 1=Important, 2=Critical
        published:
          type: boolean
          example: true
        release_date:
          type: string
          format: date
          example: '2025-09-11'
        created_at:
          type: string
          format: date-time
          example: '2025-09-16T22:34:26Z'
        updated_at:
          type: string
          format: date-time
          example: '2025-09-16T22:34:26Z'
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: System Admin
        public_url:
          type: string
          nullable: true
          example: https://workforce.mangoapps.com/changelog/1
    ChangelogEntryInput:
      type: object
      required:
      - version
      - title
      - content
      - release_date
      properties:
        version:
          type: string
          example: '1.7'
          description: Main version number (e.g., 1.6, 2.0)
        patch_version:
          type: string
          nullable: true
          example: '1'
          description: Patch number for minor releases (optional)
        title:
          type: string
          example: Enhanced AI capabilities and performance improvements
          description: Title of the changelog entry
        description:
          type: string
          nullable: true
          example: Next major release with improved AI models
          description: Brief description of the release (optional)
        content:
          type: string
          example: |
            ### New Features

            * Support for GPT-5 models
            * Improved code generation accuracy

            ### Performance Improvements

            * 50% faster response times
            * Reduced memory usage
          description: Markdown content of the changelog entry
        category:
          type: string
          enum:
          - general
          - feature
          - improvement
          - fix
          - patch
          default: general
          example: feature
        priority:
          type: integer
          enum:
          - 0
          - 1
          - 2
          default: 0
          example: 1
          description: 0=Normal, 1=Important, 2=Critical
        published:
          type: boolean
          default: false
          example: true
          description: Whether the entry should be published immediately
        release_date:
          type: string
          format: date
          example: '2025-10-01'
          description: Release date of the version
        metadata:
          type: object
          nullable: true
          example: {}
          description: Additional metadata (optional)
    ChangelogEntryResponse:
      type: object
      properties:
        id:
          type: integer
          example: 1
        version:
          type: string
          example: 1.7.1
        title:
          type: string
          example: Enhanced AI capabilities and performance improvements
        published:
          type: boolean
          example: true
        release_date:
          type: string
          format: date
          example: '2025-10-01'
        created_at:
          type: string
          format: date-time
          example: '2025-09-16T22:34:26Z'
        public_url:
          type: string
          nullable: true
          example: https://workforce.mangoapps.com/changelog/1
    RecognitionPerson:
      type: object
      description: 'A person as every Recognitions list names them. Deliberately identity-only
        — these payloads go to every employee, and the cards they mirror show a name
        and a face, nothing else. An automated lifecycle award reports a null `id`
        with the name `System (Automated)` rather than leaking the system principal.

        '
      required:
      - id
      - name
      properties:
        id:
          type: integer
          nullable: true
          example: 412
        name:
          type: string
          example: Patrick Smith
        title:
          type: string
          nullable: true
          description: Job title, when the person has one on file.
          example: Store Manager
        image:
          type: string
          nullable: true
          description: Absolute avatar URL (native clients can't resolve a relative
            path), or null when it can't be resolved.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey.../a.jpg
    WikiDashboardRow:
      type: object
      description: 'One wiki row in a Wikis dashboard list. Matches the fields the
        web dashboard row renders: `icon` always carries a usable FontAwesome class
        (falling back to `fas fa-book`) and `color` is whitelist-coerced to a hex
        value, so clients never receive an unusable icon or colour.

        '
      required:
      - id
      - title
      - icon
      - color
      properties:
        id:
          type: integer
          example: 17
        title:
          type: string
          example: Shift Swap Guidelines
        icon:
          type: string
          description: FontAwesome class; defaults to `fas fa-book`.
          example: fas fa-book-open
        color:
          type: string
          description: Hex colour (whitelist-coerced; falls back to `#6c757d`).
          example: "#3b7ddd"
    WikiCard:
      type: object
      description: 'One wiki''s display fields for the Wikis list. `icon` falls back
        to `fas fa-book` and `color` is whitelist-coerced to a safe hex, so clients
        never receive an unusable value. `bookmarked` is the calling user''s bookmark
        state for this wiki, resolved from the same store as the `?filter=bookmarked`
        scope — so a list screen can draw a filled or unfilled bookmark per row without
        a detail call per card.

        '
      required:
      - id
      - title
      - icon
      - color
      - bookmarked
      properties:
        id:
          type: integer
          example: 42
        title:
          type: string
          example: Employee Handbook
        icon:
          type: string
          example: fas fa-book
        color:
          type: string
          example: "#2E7D32"
        bookmarked:
          type: boolean
          description: Whether the calling user has bookmarked this wiki.
          example: true
        pinned:
          type: boolean
          deprecated: true
          description: DEPRECATED mirror of `bookmarked`, kept for native builds shipped
            before the pin→bookmark rename. Computed from the same set, so the two
            can never disagree. New integrations read `bookmarked`.
          example: true
    WikiSearchHit:
      description: 'One wiki matched by GET /wikis/search — the list card fields plus
        the wiki''s description, its creator (id + name) and its tags. `description`
        is null when the wiki has none (it is one of the columns the search matches
        on). `creator_id` is the wiki''s author; `creator_name` is null when that
        user no longer exists. `tags` is an empty array when untagged.

        '
      allOf:
      - "$ref": "#/components/schemas/WikiCard"
      - type: object
        required:
        - description
        - creator_id
        - creator_name
        - tags
        properties:
          description:
            type: string
            nullable: true
            description: The wiki's short description (one of the searched columns).
            example: Runbook for the deploy pipeline
          creator_id:
            type: integer
            nullable: true
            description: Id of the user who created the wiki (`created_by_id`).
            example: 49290
          creator_name:
            type: string
            nullable: true
            example: Dana Lee
          tags:
            type: array
            items:
              type: string
            example:
            - devops
            - runbook
    WikiSubTreeNode:
      description: 'One node in a wiki''s FULL sub-wiki tree — the card fields plus
        the page status and, RECURSIVELY, that node''s own published visible sub-wikis
        (empty at the leaves). Used by GET /wikis/{id} (`sub_wikis`).

        '
      allOf:
      - "$ref": "#/components/schemas/WikiCard"
      - type: object
        required:
        - status
        - sub_wikis
        properties:
          status:
            type: string
            enum:
            - draft
            - published
            - archived
            example: published
          sub_wikis:
            type: array
            description: This node's own sub-wikis, same shape, recursively.
            items:
              "$ref": "#/components/schemas/WikiSubTreeNode"
    IdeaComment:
      type: object
      description: |
        One comment (or reply) on an idea — text + @mentions only, with no attachments (matching the web composer, which posts a bare `comment[body]` with no file field). Threading is ONE level: a top-level comment inlines its direct replies, and a reply always has `replies: []`. Soft-deleted comments never appear.

        **`replies` is capped at 20 per comment.** `per_page` bounds the top-level list only, so a busy thread would otherwise return every reply it has. `replies_count` is the TRUE total (it can exceed `replies.length`) and `replies_truncated` tells you the array is partial — page the rest with `GET /ideas/{idea_id}/comments?parent_comment_id={id}`, whose default `per_page` is the same 20, so page 2 continues exactly where `replies` stopped.
      required:
      - id
      - idea_id
      - parent_comment_id
      - author
      - body
      - mentioned_user_ids
      - created_at
      - replies_count
      - replies_truncated
      - replies
      - can_edit
      - can_delete
      properties:
        id:
          type: integer
          example: 8842
        idea_id:
          type: integer
          example: 42
        parent_comment_id:
          type: integer
          nullable: true
          description: The comment this replies to; null for a top-level comment.
          example:
        author:
          type: object
          required:
          - id
          - name
          properties:
            id:
              type: integer
              example: 49290
            name:
              type: string
              example: Casey Poster
            photo:
              type: string
              nullable: true
              description: Absolute avatar URL.
        body:
          type: string
          description: The comment text, with raw `@[Name](mention:id)` mention tokens
            preserved so a client can linkify them.
          example: Great idea @[Dana Lee](mention:51102)
        mentioned_user_ids:
          type: array
          description: Deduped ids of the users tagged in `body`.
          items:
            type: integer
          example:
          - 51102
        edited_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-31T09:30:28Z'
        replies_count:
          type: integer
          description: The TRUE number of direct replies, from a COUNT — not the length
            of `replies`, which is capped at 20. Always 0 on a reply (one level of
            threading). Compare it with `replies.length`, or just read `replies_truncated`,
            to know whether there is more to fetch.
          example: 2
        replies_truncated:
          type: boolean
          description: True when `replies` holds only the first 20 of `replies_count`
            replies. Fetch the remainder with `GET /ideas/{idea_id}/comments?parent_comment_id={id}&page=2`.
            Always false on a reply.
          example: false
        replies:
          type: array
          maxItems: 20
          description: The direct replies, oldest first, CAPPED at 20 — see `replies_count`
            and `replies_truncated`. Always empty on a reply (one level of threading).
          items:
            "$ref": "#/components/schemas/IdeaComment"
        can_edit:
          type: boolean
          description: Whether the caller may PATCH this comment — the author alone,
            with no time window.
          example: true
        can_delete:
          type: boolean
          description: Whether the caller may delete it — the author OR an Ideas admin,
            with no time window.
          example: true
    IdeaVoteState:
      type: object
      description: 'An idea''s vote state after a cast/remove, returned by POST/DELETE
        /ideas/{idea_id}/vote. `vote_count` / `has_voted` match the feed card''s key
        names so a client can patch its cached card in place.

        '
      required:
      - idea
      properties:
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
        idea:
          type: object
          required:
          - id
          - vote_count
          - has_voted
          - voting_closed
          - vote_change_allowed
          properties:
            id:
              type: integer
              example: 42
            vote_count:
              type: integer
              description: The idea's total upvotes after this change (denormalized
                up_votes_count).
              example: 18
            has_voted:
              type: boolean
              description: Whether the CALLER now has an upvote on this idea.
              example: true
            voting_closed:
              type: boolean
              description: Whether the idea's voting-close date has passed (no further
                casts).
              example: false
            vote_change_allowed:
              type: boolean
              description: Whether the "Allow removing an upvote" workspace setting
                is on. When false, DELETE /ideas/{idea_id}/vote returns 403 — the
                client should hide the remove-vote affordance.
              example: true
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    WikiCommentAttachment:
      type: object
      description: One file attached to a wiki comment.
      required:
      - id
      - filename
      - content_type
      - byte_size
      - image
      - url
      - download_url
      properties:
        id:
          type: integer
          example: 2634
        filename:
          type: string
          example: notes.txt
        content_type:
          type: string
          example: text/plain
        byte_size:
          type: integer
          example: 16
        image:
          type: boolean
          description: True for image blobs (render as a thumbnail).
          example: false
        url:
          type: string
          description: 'Absolute ActiveStorage URL for opening the attachment. An
            IMAGE is served `Content-Disposition: inline` so it can be previewed in
            place; every other file — a PDF above all — is served `Content-Disposition:
            attachment`, because a client with no built-in PDF renderer can neither
            display an inline PDF nor hand it to a download, so the tap does nothing
            at all.

            '
          example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/notes.txt?disposition=attachment
        download_url:
          type: string
          description: 'Absolute URL that ALWAYS responds `Content-Disposition: attachment`
            — the target for an explicit Download action, images included. Identical
            to `url` for every non-image attachment.

            '
          example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/notes.txt?disposition=attachment
    WikiComment:
      type: object
      description: 'A wiki comment. Top-level comments inline their direct replies
        in `replies` (ONE level — a reply''s own `replies` is always empty).

        '
      required:
      - id
      - wiki_id
      - parent_comment_id
      - author
      - body
      - mentioned_user_ids
      - created_at
      - replies_count
      - replies
      - reactions
      - attachments
      - can_edit
      - can_delete
      properties:
        id:
          type: integer
          example: 22
        wiki_id:
          type: integer
          example: 42
        parent_comment_id:
          type: integer
          nullable: true
          description: Null for a top-level comment; the parent's id for a reply.
          example:
        author:
          type: object
          nullable: true
          description: Null if the author was deleted.
          properties:
            id:
              type: integer
              example: 1884
            name:
              type: string
              example: Georgia Fitzpatrick
            avatar_url:
              type: string
              example: https://…/avatar.png
        body:
          type: string
          description: Comment text with raw `@[Name](mention:id)` mention tokens
            preserved.
          example: Great page @[Casey Poster](mention:49290)
        mentioned_user_ids:
          type: array
          description: Deduped user ids mentioned in the body.
          items:
            type: integer
          example:
          - 49290
        edited_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        replies_count:
          type: integer
          description: Number of direct replies (0 on a reply).
          example: 2
        replies:
          type: array
          description: Direct replies, oldest first. Empty on a reply (one level deep).
          items:
            "$ref": "#/components/schemas/WikiComment"
        reactions:
          type: object
          properties:
            total:
              type: integer
              example: 3
            top_emojis:
              type: array
              description: Up to 3 emojis by count, highest first.
              items:
                type: object
                properties:
                  emoji:
                    type: string
                    example: "\U0001F44D"
                  count:
                    type: integer
                    example: 2
            my_reactions:
              type: array
              description: Emojis the calling user reacted with.
              items:
                type: string
              example:
              - "\U0001F44D"
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/WikiCommentAttachment"
        can_edit:
          type: boolean
          description: 'Whether the CALLING user may edit this comment: an admin always
            may; otherwise only the author, and only within 15 minutes of posting.

            '
          example: true
        can_delete:
          type: boolean
          description: 'Whether the CALLING user may delete this comment: an admin
            always may; otherwise only the author, and only within 5 minutes of posting.

            '
          example: false
    ResponseMeta:
      type: object
      description: Standard response metadata
      additionalProperties: true
      properties:
        request_id:
          type: string
          description: Unique request identifier
        generated_at:
          type: string
          format: date-time
          description: Timestamp when the response was generated
        execution_time_ms:
          type: number
          description: Server processing time in milliseconds
        total_count:
          type: integer
          description: Total number of items
        total_pages:
          type: integer
          description: Total number of pages
        current_page:
          type: integer
          description: Current page number
        per_page:
          type: integer
          description: Number of items per page
    User:
      type: object
      properties:
        id:
          type: integer
          example: 1
        uid:
          type: string
          description: Unique employee identifier (mango_employee_id)
          example: E1234567
          nullable: true
        email:
          type: string
          format: email
          example: user@example.com
        first_name:
          type: string
          example: John
        last_name:
          type: string
          example: Doe
        full_name:
          type: string
          example: John Doe
        role:
          type: string
          example: employee
        active:
          type: boolean
          example: true
        avatar_url:
          type: string
          format: uri
          nullable: true
        phone:
          type: string
          nullable: true
        job_title:
          type: string
          nullable: true
          example: Software Engineer
        department:
          type: string
          nullable: true
          example: Engineering
        organizational_role:
          type: string
          nullable: true
          example: Senior Developer
        hire_date:
          type: string
          format: date-time
          nullable: true
          example: '2023-01-15T00:00:00Z'
        employment_status:
          type: string
          nullable: true
          example: full_time
        employee_type:
          type: string
          nullable: true
          example: Full-Time
        manager:
          type: object
          nullable: true
          properties:
            uid:
              type: string
              example: E1234567
            name:
              type: string
              example: Jane Smith
            email:
              type: string
              format: email
              example: jane.smith@example.com
        locations:
          type: array
          description: 'Enhanced location information with IDs (NEW: includes id,
            name, and address)'
          items:
            type: object
            properties:
              id:
                type: integer
                description: Location ID for reference in other API calls
                example: 265
              name:
                type: string
                description: Location name
                example: Issaquah Office
              address:
                type: string
                nullable: true
                description: Physical address of the location
                example: 1495 11th Avenue Northwest
        marketplace_apps:
          type: array
          description: Marketplace apps enabled for the user's business (NEW)
          items:
            type: object
            properties:
              id:
                type: integer
                description: Marketplace app ID
                example: 6
              name:
                type: string
                description: App display name
                example: Performance Management
              slug:
                type: string
                description: App URL slug
                example: employee-performance-management
              category:
                type: string
                description: App category
                example: performance
        termination_date:
          type: string
          format: date-time
          nullable: true
        termination_type:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          example: '2023-01-01T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
        last_activity_at:
          type: string
          format: date-time
          nullable: true
        preferences:
          type: object
          description: User preferences
    Business:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        subdomain:
          type: string
        timezone:
          type: string
          example: UTC
        logo_url:
          type: string
          format: uri
          nullable: true
        branding:
          type: object
          nullable: true
          description: Branding color configuration for mobile theming (HEX format)
          properties:
            primary_button_color:
              type: string
              example: "#2e63b3"
            secondary_button_color:
              type: string
              example: "#6c757d"
            status_success_color:
              type: string
              example: "#198754"
            status_error_color:
              type: string
              example: "#dc3545"
            status_warning_color:
              type: string
              example: "#ffc107"
            status_info_color:
              type: string
              example: "#0dcaf0"
            mobile_header_background:
              type: string
              example: "#2e63b3"
            mobile_header_text:
              type: string
              example: "#FFFFFF"
            mobile_footer_background:
              type: string
              example: "#FFFFFF"
            mobile_footer_icon_active:
              type: string
              example: "#2e63b3"
            mobile_footer_icon_inactive:
              type: string
              example: "#6c757d"
        settings:
          type: object
          description: Business settings for mobile app
    Shift:
      type: object
      properties:
        id:
          type: integer
          example: 123
        name:
          type: string
          example: Morning Shift
        description:
          type: string
          nullable: true
        start_time:
          type: string
          format: date-time
          example: '2025-01-27T09:00:00Z'
        end_time:
          type: string
          format: date-time
          example: '2025-01-27T17:00:00Z'
        formatted_date:
          type: string
          description: Human-readable date format
          example: Monday, Jan 27
        formatted_time:
          type: string
          description: Human-readable time range
          example: 9:00 AM - 5:00 PM
        status:
          type: string
          enum:
          - scheduled
          - completed
          - cancelled
          - open
          example: scheduled
        urgent:
          type: boolean
          description: Whether this shift is marked as urgent
          example: false
        needs_coverage:
          type: boolean
          description: Whether this shift needs coverage
          example: false
        can_list_for_coverage:
          type: boolean
          description: Whether the current user can list this shift for coverage in
            the marketplace
          example: true
        is_already_listed:
          type: boolean
          description: Whether this shift is already listed in the marketplace by
            the current user
          example: false
        required_users:
          type: integer
          description: Number of users required for this shift
        location:
          type: object
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Downtown Store
            address:
              type: string
              nullable: true
            phone:
              type: string
              nullable: true
        users:
          type: array
          items:
            "$ref": "#/components/schemas/User"
        available_actions:
          type: array
          description: Actions available to the current user for this shift
          items:
            type: string
            enum:
            - view_details
            - request_coverage
            - report_lateness
            - view_teammates
            - claim
          example:
          - view_details
          - request_coverage
          - view_teammates
        teammates:
          type: array
          description: Other users assigned to this shift (when include=teammates)
          nullable: true
          items:
            type: object
            properties:
              id:
                type: integer
                description: User ID
              name:
                type: string
                description: User full name
              job_title:
                type: string
                nullable: true
                description: User's job title/role
              profile_photo_url:
                type: string
                nullable: true
                description: Full-size profile photo URL (200x200) or avatar placeholder
              profile_photo_thumbnail_url:
                type: string
                nullable: true
                description: Thumbnail profile photo URL (40x40) optimized for list
                  views
        listed_by:
          type: object
          nullable: true
          description: User who listed this shift in the marketplace (teammate format,
            present for marketplace shifts)
          properties:
            id:
              type: integer
              description: User ID
            name:
              type: string
              description: User full name
            job_title:
              type: string
              nullable: true
              description: User's job title/role
            profile_photo_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or avatar placeholder
            profile_photo_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        picked_by:
          type: object
          nullable: true
          description: User who claimed/picked up this shift from the marketplace
            (teammate format, present when shift was claimed)
          properties:
            id:
              type: integer
              description: User ID
            name:
              type: string
              description: User full name
            job_title:
              type: string
              nullable: true
              description: User's job title/role
            profile_photo_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or avatar placeholder
            profile_photo_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        marketplace_info:
          type: object
          nullable: true
          description: Marketplace listing information (present when shift has marketplace
            listing)
          properties:
            has_listing:
              type: boolean
              description: Whether this shift has an active marketplace listing
              example: true
            listing_id:
              type: integer
              description: ID of the marketplace listing
              example: 880
            listing_status:
              type: string
              enum:
              - open
              - filled
              - closed
              - cancelled
              description: Current status of the marketplace listing
              example: open
            listing_type:
              type: string
              enum:
              - pickup
              - trade_only
              - both
              description: Type of marketplace listing - pickup (direct claim), trade_only
                (requires application/trade), or both (accepts either)
              example: pickup
            price:
              type: number
              format: float
              description: Price offered for the shift
              example: 0.01
            currency:
              type: string
              description: Currency code (e.g., USD)
              example: USD
            listed_by:
              type: integer
              description: User ID of the person who listed the shift (legacy field,
                use listed_by object instead)
              example: 1487
            listed_at:
              type: string
              format: date-time
              description: When the shift was listed in the marketplace
              example: '2025-12-01T10:05:00Z'
        notes:
          type: string
          nullable: true
          description: Shift notes (when include=notes)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    AttendanceRecord:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        shift_id:
          type: integer
        business_id:
          type: integer
        check_in_time:
          type: string
          format: date-time
          nullable: true
        check_out_time:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum:
          - pending
          - on_time
          - late
          - missed
          - completed
          - absence_reported
          - excused
          - no_show
        location_verified:
          type: boolean
          default: false
        photo_verification_required:
          type: boolean
          default: false
        photo_verification_failed_reason:
          type: string
          nullable: true
        is_unknown_device:
          type: boolean
          default: false
        requires_review:
          type: boolean
          default: false
        review_reason:
          type: string
          nullable: true
        reviewed_by_user_id:
          type: integer
          nullable: true
        reviewed_at:
          type: string
          format: date-time
          nullable: true
        excused_by_user_id:
          type: integer
          nullable: true
        excused_at:
          type: string
          format: date-time
          nullable: true
        absence_report_id:
          type: integer
          nullable: true
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        user:
          "$ref": "#/components/schemas/User"
        shift:
          allOf:
          - "$ref": "#/components/schemas/Shift"
          nullable: true
          description: 'The record''s shift. A SUBSET of the Shift schema: id, name,
            start_time, end_time, formatted_date, formatted_time — plus status, location
            and location_department when `include=shift_details` is requested. `formatted_date`
            ("Wednesday, Aug 05") and `formatted_time` ("01:30 PM - 09:30 PM") are
            rendered in the SHIFT''s own timezone, are byte-identical to what `GET
            /shifts` returns for the same shift.id, and do not vary with the caller''s
            profile timezone — clients should prefer them over formatting `start_time`
            locally, which has no usable zone once an SDK decodes it to a bare instant.
            `formatted_date` always names the shift''s START day, so it is correct
            for a shift crossing midnight.'
        break_records:
          type: array
          items:
            "$ref": "#/components/schemas/BreakRecord"
    BreakRecord:
      type: object
      properties:
        id:
          type: integer
        attendance_record_id:
          type: integer
        break_type_id:
          type: integer
        start_time:
          type: string
          format: date-time
          nullable: true
        end_time:
          type: string
          format: date-time
          nullable: true
        duration_minutes:
          type: integer
          nullable: true
        required_duration_minutes:
          type: integer
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - skipped
          - interrupted
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
    MissingPunchRequest:
      type: object
      description: 'An employee-submitted punch correction. Stored as an AttendanceRecord
        with punch_source=''missing_punch_request'' and flagged for manager review
        — it is a REQUEST, not an approved punch, until a manager acts on it.

        '
      properties:
        id:
          type: integer
        status:
          type: string
          description: "`pending_review` until a manager acts; afterwards the underlying
            attendance status.\n"
        pending_review:
          type: boolean
        check_in_time:
          type: string
          format: date-time
        check_out_time:
          type: string
          format: date-time
        reason:
          type: string
          description: The employee's stated reason for the correction.
        reviewed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        can_cancel:
          type: boolean
          description: 'Whether DELETE would succeed — true only while no manager
            has acted. Use it to decide whether to show a cancel control instead of
            discovering the rule from a 422.

            '
        shift_id:
          type: integer
          description: Detailed responses only (create). The supporting ad-hoc shift.
        location_name:
          type: string
          nullable: true
          description: Detailed responses only.
        breaks:
          type: array
          description: 'Detailed responses only. Required break types the employee
            did not submit appear with status `skipped`.

            '
          items:
            type: object
            properties:
              id:
                type: integer
              break_type_id:
                type: integer
              break_type_name:
                type: string
              start_time:
                type: string
                format: date-time
                nullable: true
              end_time:
                type: string
                format: date-time
                nullable: true
              duration_minutes:
                type: integer
                nullable: true
              status:
                type: string
    ShiftFeedback:
      type: object
      properties:
        id:
          type: integer
          example: 789
        attendance_record_id:
          type: integer
          example: 123
          description: ID of the associated attendance record
        user_id:
          type: integer
          example: 456
          description: ID of the user who submitted feedback
        rating:
          type: integer
          minimum: 1
          maximum: 5
          example: 4
          description: Overall shift rating (1-5 stars)
        feedback_text:
          type: string
          nullable: true
          example: Great shift, but the workspace was a bit noisy.
          description: Detailed feedback text (optional)
        shift_difficulty:
          type: integer
          minimum: 1
          maximum: 5
          nullable: true
          example: 3
          description: How difficult was the shift (1=Easy, 5=Very Hard)
        would_work_again:
          type: boolean
          nullable: true
          example: true
          description: Would the user work this shift again
        can_edit:
          type: boolean
          example: true
          description: Whether the feedback can still be edited (within 24 hours)
        edit_window_expires_at:
          type: string
          format: date-time
          example: '2025-10-11T16:00:00Z'
          description: When the edit window expires (24 hours from submission)
        created_at:
          type: string
          format: date-time
          example: '2025-10-10T16:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2025-10-10T16:00:00Z'
        attendance_record:
          "$ref": "#/components/schemas/AttendanceRecord"
        shift:
          "$ref": "#/components/schemas/Shift"
        user:
          "$ref": "#/components/schemas/User"
    ShiftMarketplaceListing:
      type: object
      description: A marketplace listing for shifts (pickup, trade_only, or both)
      properties:
        id:
          type: integer
          example: 1
        listing_type:
          type: string
          enum:
          - pickup
          - trade_only
          - both
          example: pickup
          description: Type of listing - pickup (direct claim), trade_only (requires
            application), or both
        status:
          type: string
          enum:
          - open
          - filled
          - closed
          - cancelled
          example: open
        price:
          type: number
          nullable: true
          example: 0.01
        currency:
          type: string
          example: USD
        notes:
          type: string
          nullable: true
          example: Need someone to cover this shift
        urgent:
          type: boolean
          description: Whether this listing is marked as urgent
          example: false
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        claimed_at:
          type: string
          format: date-time
          nullable: true
        shift:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            start_time:
              type: string
              format: date-time
            end_time:
              type: string
              format: date-time
            date:
              type: string
              format: date
            formatted_date:
              type: string
            formatted_time:
              type: string
            location:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        claimed_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        can_claim:
          type: boolean
          description: Whether current user can claim this listing
        accepts_applications:
          type: boolean
          description: Whether this listing accepts applications (trade_only or both)
        is_trade_only:
          type: boolean
          description: Whether this is a trade-only listing
        has_applied:
          type: boolean
          description: Whether current user has applied to this listing
        application_status:
          type: string
          nullable: true
          enum:
          - pending
          - accepted
          - rejected
          - withdrawn
          description: Current user's application status (if applied)
        application_id:
          type: integer
          nullable: true
          description: Current user's application ID (if applied)
        is_owner:
          type: boolean
          description: Whether current user owns this listing
        can_edit:
          type: boolean
        can_delete:
          type: boolean
        applications:
          type: array
          description: Owner-only application summaries for this listing
          items:
            type: object
            properties:
              id:
                type: integer
              status:
                type: string
                enum:
                - pending
                - accepted
                - rejected
                - withdrawn
              notes:
                type: string
                nullable: true
              created_at:
                type: string
                format: date-time
              offered_shift:
                type: object
                nullable: true
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  formatted_date:
                    type: string
                  formatted_time:
                    type: string
                  location:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
              applicant:
                type: object
                nullable: true
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  avatar_url:
                    type: string
                    nullable: true
                  avatar_thumbnail_url:
                    type: string
                    nullable: true
        applications_count:
          type: integer
          description: Total applications (owner only)
        pending_applications_count:
          type: integer
          description: Pending applications count (owner only)
    ShiftMarketplaceApplication:
      type: object
      description: An application to a trade listing
      properties:
        id:
          type: integer
          example: 1
        status:
          type: string
          enum:
          - pending
          - accepted
          - rejected
          - withdrawn
          example: pending
        notes:
          type: string
          nullable: true
          example: I can work this shift
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        listing:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            listing_type:
              type: string
            status:
              type: string
            shift:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
                start_time:
                  type: string
                  format: date-time
                end_time:
                  type: string
                  format: date-time
                formatted_date:
                  type: string
                formatted_time:
                  type: string
                location:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
            created_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
                email:
                  type: string
                avatar_url:
                  type: string
                  nullable: true
                  description: Full-size profile photo URL (200x200) or fallback to
                    initials-based avatar
                avatar_thumbnail_url:
                  type: string
                  nullable: true
                  description: Thumbnail profile photo URL (40x40) optimized for list
                    views
        applicant:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        is_applicant:
          type: boolean
          description: Whether current user is the applicant
        is_listing_owner:
          type: boolean
          description: Whether current user owns the listing
        can_accept:
          type: boolean
          description: Whether current user can accept this application
        can_reject:
          type: boolean
          description: Whether current user can reject this application
        can_withdraw:
          type: boolean
          description: Whether current user can withdraw this application
    ShiftDirectOffer:
      type: object
      description: A direct peer-to-peer shift offer
      properties:
        id:
          type: integer
          example: 1
        status:
          type: string
          enum:
          - pending
          - accepted
          - declined
          - expired
          - cancelled
          example: pending
        notes:
          type: string
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        shift:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            start_time:
              type: string
              format: date-time
            end_time:
              type: string
              format: date-time
            formatted_date:
              type: string
            formatted_time:
              type: string
            location:
              type: string
              nullable: true
        from_user:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        to_user:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        is_sender:
          type: boolean
          description: Whether current user sent this offer
        is_recipient:
          type: boolean
          description: Whether current user received this offer
        can_accept:
          type: boolean
          description: Whether current user can accept this offer
        can_decline:
          type: boolean
          description: Whether current user can decline this offer
        can_cancel:
          type: boolean
          description: Whether current user can cancel this offer (sender only, pending
            offers)
    BreakType:
      type: object
      properties:
        id:
          type: integer
        business_id:
          type: integer
        name:
          type: string
        duration_minutes:
          type: integer
        is_required:
          type: boolean
        is_paid:
          type: boolean
        description:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PiggybackMeta:
      type: object
      description: Piggyback response metadata containing additional contextual data
      properties:
        piggyback:
          type: object
          description: Additional data included with the response to reduce subsequent
            API calls
          properties:
            business_settings:
              type: object
              description: Business configuration and branding information
              properties:
                id:
                  type: integer
                name:
                  type: string
                subdomain:
                  type: string
                timezone:
                  type: string
                logo_url:
                  type: string
                  nullable: true
                industry:
                  type: string
                  nullable: true
                employee_count:
                  type: integer
                company_size:
                  type: string
                settings:
                  type: object
            user_preferences:
              type: object
              description: User preferences and settings
              properties:
                timezone:
                  type: string
                locale:
                  type: string
                date_format:
                  type: string
                time_format:
                  type: string
                notifications_enabled:
                  type: boolean
                default_view:
                  type: string
                theme:
                  type: string
            recent_shifts:
              type: array
              description: Recent completed shifts (last 5)
              items:
                type: object
                properties:
                  id:
                    type: integer
                  title:
                    type: string
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  location:
                    type: string
                    nullable: true
                  status:
                    type: string
                  duration_hours:
                    type: number
            upcoming_shifts:
              type: array
              description: Upcoming scheduled shifts (next 10)
              items:
                type: object
                properties:
                  id:
                    type: integer
                  title:
                    type: string
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  location:
                    type: string
                    nullable: true
                  status:
                    type: string
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  time_until_start:
                    type: string
                    nullable: true
            notifications:
              type: array
              description: Unread notifications (last 20)
              items:
                type: object
                properties:
                  id:
                    type: integer
                  type:
                    type: string
                  title:
                    type: string
                  message:
                    type: string
                  created_at:
                    type: string
                    format: date-time
                  priority:
                    type: string
                    enum:
                    - normal
                    - high
                    - urgent
                  action_url:
                    type: string
                    nullable: true
            business_stats:
              type: object
              description: Business statistics (admin/manager only)
              properties:
                total_employees:
                  type: integer
                shifts_this_week:
                  type: integer
                shifts_today:
                  type: integer
                attendance_rate:
                  type: number
                open_shifts:
                  type: integer
                pending_requests:
                  type: integer
            feature_flags:
              type: array
              description: Enabled feature flags for this business
              items:
                type: string
            system_status:
              type: object
              description: System status and announcements
              properties:
                api_version:
                  type: string
                server_time:
                  type: string
                  format: date-time
                maintenance_mode:
                  type: boolean
                announcements:
                  type: array
                  items:
                    type: object
            server_recommendations:
              type: object
              description: AI-powered recommendations and suggested actions
              properties:
                next_actions:
                  type: array
                  items:
                    type: string
                setup_needed:
                  type: array
                  items:
                    type: string
                quick_actions:
                  type: array
                  items:
                    type: string
                scheduling_tips:
                  type: array
                  items:
                    type: string
                notifications:
                  type: array
                  items:
                    type: string
                app_tips:
                  type: array
                  items:
                    type: string
        query_info:
          type: object
          description: Information about the piggyback data generation
          properties:
            included_data:
              type: array
              description: List of piggyback data types that were included
              items:
                type: string
            execution_time_ms:
              type: number
              description: Time taken to generate piggyback data in milliseconds
            cache_hits:
              type: integer
              description: Number of cache hits during data generation
            cache_misses:
              type: integer
              description: Number of cache misses during data generation
    HttpEnhancementMeta:
      type: object
      description: HTTP protocol enhancements for client performance and dynamic behavior
      properties:
        http:
          type: object
          description: HTTP enhancement metadata
          properties:
            caching:
              type: object
              description: Intelligent caching information
              properties:
                etag:
                  type: string
                  description: Entity tag for content validation
                  example: W/"abc123-def456"
                max_age:
                  type: integer
                  description: Cache max-age in seconds
                  example: 300
                private:
                  type: boolean
                  description: Whether response should be cached privately
                  example: true
                revalidate:
                  type: boolean
                  description: Whether cache must revalidate with server
                  example: false
                vary:
                  type: array
                  description: Headers that affect caching
                  items:
                    type: string
                  example:
                  - Accept
                  - Authorization
                cacheable:
                  type: boolean
                  description: Whether response is cacheable
                  example: true
            navigation:
              type: object
              description: Dynamic navigation and preload hints
              properties:
                next_actions:
                  type: array
                  description: Suggested next actions for the client
                  items:
                    type: object
                    properties:
                      rel:
                        type: string
                        description: Relationship type
                        example: edit-profile
                      href:
                        type: string
                        description: URL for the action
                        example: "/api/v1/users/me"
                      method:
                        type: string
                        description: HTTP method
                        example: PATCH
                      title:
                        type: string
                        description: Human-readable action title
                        example: Edit Profile
                      preload:
                        type: boolean
                        description: Whether to preload this resource
                        example: true
                      prefetch:
                        type: boolean
                        description: Whether to prefetch this resource
                        example: false
                redirects:
                  type: object
                  description: Conditional redirect suggestions
                  properties:
                    conditional:
                      type: object
                      description: Conditional redirects based on user state
                      additionalProperties:
                        type: string
                      example:
                        incomplete_profile: "/api/v1/users/me/complete"
                        pending_verification: "/api/v1/auth/verify"
                preload_hints:
                  type: array
                  description: Resources to preload
                  items:
                    type: object
                    properties:
                      resource:
                        type: string
                        example: "/api/v1/businesses/settings"
                      as:
                        type: string
                        example: fetch
                      priority:
                        type: string
                        enum:
                        - high
                        - medium
                        - low
                        example: high
            performance:
              type: object
              description: Performance metrics and optimization info
              properties:
                compression:
                  type: string
                  description: Compression type used
                  enum:
                  - gzip
                  - deflate
                  - none
                  example: gzip
                response_time_ms:
                  type: number
                  description: Server response time in milliseconds
                  example: 45.2
                cache_status:
                  type: string
                  description: Cache status for this request
                  enum:
                  - hit
                  - miss
                  - conditional
                  - bypass
                  example: hit
                estimated_size_bytes:
                  type: integer
                  description: Estimated response size in bytes
                  example: 2048
                optimization_score:
                  type: integer
                  description: Performance optimization score (0-100)
                  example: 85
            client_hints:
              type: object
              description: Hints for client behavior and capabilities
              properties:
                api_version:
                  type: string
                  description: Current API version
                  example: '1.0'
                rate_limit:
                  type: object
                  description: Rate limiting information
                  properties:
                    remaining:
                      type: integer
                      description: Requests remaining in current window
                      example: 98
                    reset:
                      type: integer
                      description: Unix timestamp when rate limit resets
                      example: 1640995200
                    limit:
                      type: integer
                      description: Total requests allowed per window
                      example: 100
                deprecations:
                  type: array
                  description: Deprecation warnings for used features
                  items:
                    type: object
                    properties:
                      feature:
                        type: string
                        example: API version 0.9
                      deprecated_since:
                        type: string
                        format: date
                        example: '2024-01-01'
                      removal_date:
                        type: string
                        format: date
                        example: '2024-12-31'
                      replacement:
                        type: string
                        example: API version 1.0+
                preferred_format:
                  type: string
                  description: Recommended response format for client
                  example: application/json
                capabilities:
                  type: array
                  description: Detected client capabilities
                  items:
                    type: string
                  example:
                  - compression
                  - conditional_requests
                  - mobile_optimized
    MarketplaceApp:
      type: object
      properties:
        id:
          type: integer
          description: Unique identifier for the marketplace app
          example: 42
        slug:
          type: string
          description: URL-friendly identifier for the app
          example: employee-performance-management
        name:
          type: string
          description: Display name of the app
          example: Employee Performance Management
        description:
          type: string
          description: Short description of the app
          example: Comprehensive performance review and goal management system
        icon:
          type: object
          description: App icon information
          properties:
            url:
              type: string
              description: URL to the app icon
              example: https://example.com/icon.png
            type:
              type: string
              enum:
              - icon_class
              - url
              - default
              description: |
                Type of icon source. This is the authoritative, versioned contract —
                the same three values on every app-listing endpoint:
                - icon_class: Font Awesome 5 class carried in `url`
                - url: HTTP(S) URL or asset path carried in `url`. Icons stored as
                  Active Storage attachments report as `url` too — where the server
                  keeps the image is not part of the contract.
                - default: built-in fallback asset path
                Adding a value is a BREAKING change (clients decode this as a strict
                enum), so it requires an API version bump. Server-side source of
                truth: MobileViewSupport::API_ICON_TYPES.
              example: url
        category:
          type: string
          description: App category
          example: hr
        version:
          type: string
          description: Current version of the app
          example: 2.1.0
        featured:
          type: boolean
          description: Whether the app is featured
          example: false
        sort_order:
          type: integer
          description: Display order for the app
          example: 10
        url:
          type: string
          description: Launch URL for the app
          example: "/apps/employee-performance-management"
        configuration:
          type: object
          description: Business-specific configuration for this app
          additionalProperties: true
          example:
            enable_self_reviews: true
            review_cycle_months: 12
        enabled_at:
          type: string
          format: date-time
          description: When the app was enabled for this business
          example: '2024-01-15T10:30:00Z'
        metadata:
          type: object
          description: Additional app metadata
          additionalProperties: true
          example:
            requires_training: false
            setup_time_minutes: 30
      required:
      - id
      - slug
      - name
      - category
      - url
    CoreApp:
      type: object
      properties:
        id:
          type: string
          description: Unique identifier for the core app
          example: shifts_scheduling
        slug:
          type: string
          description: URL-friendly identifier for the app
          example: shifts_scheduling
        name:
          type: string
          description: Display name of the core app
          example: Shifts & Scheduling
        description:
          type: string
          description: Description of the core app functionality
          example: Complete shift scheduling and management system with team calendar
        icon:
          type: object
          description: App icon information
          properties:
            url:
              type: string
              description: Icon class or URL
              example: fas fa-calendar-alt
            type:
              type: string
              enum:
              - icon_class
              - url
              - default
              description: |
                Type of icon source. This is the authoritative, versioned contract —
                the same three values on every app-listing endpoint:
                - icon_class: Font Awesome 5 class carried in `url`
                - url: HTTP(S) URL or asset path carried in `url`. Icons stored as
                  Active Storage attachments report as `url` too — where the server
                  keeps the image is not part of the contract.
                - default: built-in fallback asset path
                Adding a value is a BREAKING change (clients decode this as a strict
                enum), so it requires an API version bump. Server-side source of
                truth: MobileViewSupport::API_ICON_TYPES.
              example: icon_class
        category:
          type: string
          description: App category (always 'core' for core apps)
          example: core
        version:
          type: string
          description: Current version of the app
          example: 1.0.0
        color:
          type: string
          description: UI color theme for the app
          example: primary
        enabled:
          type: boolean
          description: Whether the core app is enabled for this business
          example: true
        features:
          type: array
          description: List of key features provided by this core app
          items:
            type: string
          example:
          - Shift Creation
          - Team Calendar
          - Availability Management
          - Schedule Templates
        url:
          type: string
          description: Direct URL path to access the core app
          example: "/shifts"
        metadata:
          type: object
          description: Additional metadata about the core app
          properties:
            app_type:
              type: string
              example: core
            always_available:
              type: boolean
              example: true
            basic_workforce_feature:
              type: boolean
              example: true
            advanced_feature:
              type: boolean
              example: false
          additionalProperties: true
      required:
      - id
      - slug
      - name
      - category
      - enabled
      - url
    ConsolidatedApp:
      type: object
      description: Unified app schema combining both core and marketplace apps
      properties:
        id:
          oneOf:
          - type: string
          - type: integer
          description: Unique identifier (string for core apps, integer for marketplace
            apps)
          example: shifts_scheduling
        slug:
          type: string
          description: URL-friendly identifier for the app
          example: shifts_scheduling
        name:
          type: string
          description: Display name of the app
          example: Shifts & Scheduling
        description:
          type: string
          description: Description of the app functionality
          example: Complete shift scheduling and management system
        type:
          type: string
          enum:
          - core
          - marketplace
          description: Type of app (core or marketplace)
          example: core
        icon:
          type: object
          description: |
            App icon information. For mobile clients, always use the `url` field with `type: icon_class`.
            Font Awesome 5 icon classes are provided for all apps to ensure consistent mobile rendering.
          properties:
            url:
              type: string
              description: |
                Font Awesome icon class (when type is "icon_class") or URL/path (when type is "url" or "default").
                Mobile clients should always use this field for rendering icons.
              example: fas fa-calendar-alt
            type:
              type: string
              enum:
              - icon_class
              - url
              - default
              description: |
                Type of icon source. This is the authoritative, versioned contract —
                the same three values on every app-listing endpoint:
                - icon_class: Font Awesome 5 class (recommended for mobile)
                - url: Web URL or asset path. Icons stored as Active Storage
                  attachments report as `url` too — where the server keeps the image
                  is not part of the contract.
                - default: built-in fallback asset path
                Adding a value is a BREAKING change (clients decode this as a strict
                enum), so it requires an API version bump. Server-side source of
                truth: MobileViewSupport::API_ICON_TYPES.
              example: icon_class
            web_url:
              type: string
              nullable: true
              description: |
                Original SVG/image URL for web clients. Only present when the app has a custom SVG icon
                that was converted to a Font Awesome class for mobile. Web clients can use this field
                to display the original custom icon if preferred.
              example: "/assets/icons/employee-performance-management.svg"
        category:
          type: string
          description: App category grouping (e.g., "HR Management", "Workforce Management")
          example: Workforce Management
        version:
          type: string
          description: Current version of the app
          example: 1.0.0
        color:
          type: string
          description: UI color theme for the app
          example: primary
        enabled:
          type: boolean
          description: Whether the app is enabled for this business
          example: true
        featured:
          type: boolean
          description: Whether the app is featured (marketplace apps only)
          example: false
        sort_order:
          type: integer
          description: Display order for sorting apps
          example: 1
        url:
          type: string
          description: Direct URL path to access the app
          example: "/shifts"
        features:
          type: array
          description: List of key features provided by this app
          items:
            type: string
          example:
          - Shift Creation
          - Team Calendar
          - Availability Management
        configuration:
          type: object
          description: Business-specific configuration (marketplace apps only)
          additionalProperties: true
          example: {}
        enabled_at:
          type: string
          format: date-time
          description: When the app was enabled (marketplace apps only)
          example: '2024-01-15T10:30:00Z'
        metadata:
          type: object
          description: Additional metadata about the app
          additionalProperties: true
          example:
            app_type: core
            always_available: true
        pinned:
          type: boolean
          description: |
            Whether this app is pinned for the calling user (mirrors the web
            sidebar's pinned list). **Only present when
            `include_navigation=true`** — `true` for every entry in
            `pinned_apps`, `false` for every entry in `apps`.
          example: true
        has_mobile_view:
          type: boolean
          description: |
            Whether the app has a mobile-optimized (`/m/...`) view. Only present
            when `include_navigation=true`. Mobile clients never see an app with
            `false` here — those entries are filtered out of both lists.
          example: true
        mobile_url:
          type: string
          nullable: true
          description: |
            The app's `/m/...` entry point, or `null` when it has no mobile view.
            Only present when `include_navigation=true`.

            Gotcha: on mobile, when an app's navigation collapses to a single
            child page (see `navigation_items`), this is repointed at that child
            path and `navigation_items` comes back empty — so always prefer this
            value over deriving a path from `slug`.
          example: "/m/apps/ideas"
        unread_count:
          type: integer
          description: |
            Unread badge count for the app tile. Opt-in via `?dashboard=true` or
            any `?include=` value containing `dashboard`, and emitted ONLY for
            the apps that have an unread concept (`chat`, `news-feed`) and only
            when that entry is enabled. Counts come from the same source as
            `GET /api/v1/home`'s badges, so the two cannot drift. Best-effort:
            if a count query fails, the field is omitted for that app rather
            than failing the request.
          example: 7
        unacknowledged_recognitions:
          type: object
          description: |
            The recognitions the caller has RECEIVED and not yet seen — the
            confetti feed. Present on the **`recognitions`** entry only, and only
            when that entry is enabled; every other app omits it.

            Unlike `unread_count` this is **NOT behind `?dashboard=true`**: a
            badge a client forgot to ask for is a number it can fetch later, but
            a celebration it never hears about is simply never shown. Always
            present (as `{count: 0, items: []}`) once the node is there, so a
            client can tell "nothing to celebrate" from "this server predates the
            feature".

            Bounded to the newest 20 received in the last 30 days, newest first
            — the client celebrates once, not once per item. Settle each item
            afterwards with `POST /api/v1/recognitions/{posts,awards}/{id}/acknowledge`
            (or by opening its detail screen, which acknowledges on its own), or
            the next launch celebrates the same ones again. Best-effort: if the
            lookup fails the field is omitted rather than failing the app list.
          properties:
            count:
              type: integer
              description: How many items `items` carries (already capped at 20).
              example: 2
            items:
              type: array
              maxItems: 20
              items:
                type: object
                properties:
                  type:
                    type: string
                    enum:
                    - recognition_post
                    - award
                    description: Which acknowledge path settles it — `recognition_post`
                      → `/recognitions/posts/{id}/acknowledge`, `award` → `/recognitions/awards/{id}/acknowledge`.
                    example: recognition_post
                  id:
                    type: integer
                    example: 4471
                  kind:
                    type: string
                    enum:
                    - recognition
                    - award
                    - certificate
                    description: The detail API's own vocabulary, so the client keys
                      its copy off the same word on both surfaces. An automated Award
                      (anniversary, milestone, lifecycle) is a `certificate`.
                    example: recognition
                  title:
                    type: string
                    nullable: true
                    description: The award-card / award name, when there is one.
                    example: Above and Beyond
                  from:
                    type: string
                    nullable: true
                    description: Who gave it, or `null` for an anonymous give — render
                      "someone" from a `null`, never a name.
                    example: Priya Raman
                  points:
                    type: integer
                    example: 25
                  received_at:
                    type: string
                    format: date-time
                    example: '2026-08-18T09:02:11Z'
        navigation_items:
          type: array
          description: |
            The app's in-app navigation (its web sidebar tabs / mobile TabBar),
            in display order. **Only present when `include_navigation=true`.**

            **Role-aware — absence is the authorization signal.** Items the
            caller may not open are omitted, not disabled: e.g. Ideas'
            `review_queue` appears only for review-panel members (admins get no
            bypass) and its `campaigns` item disappears when the business turns
            campaigns off; Forms' `approvals` appears only for reviewers. Do not
            render an item the API didn't return.

            May be an empty array: the app exposes no sub-navigation, every tab
            was dropped for having no mobile route, the mobile single-child
            collapse folded the only item into `mobile_url`, or the app's tab
            builder raised (failures degrade to `[]`, never a 500).
          items:
            "$ref": "#/components/schemas/AppNavigationItem"
      required:
      - id
      - slug
      - name
      - type
      - category
      - enabled
      - url
    AppNavigationItem:
      type: object
      description: |
        One entry of an app's in-app navigation, as returned in
        `ConsolidatedApp.navigation_items` by
        `GET /api/v1/apps?include_navigation=true`.

        Section headers and dividers from the web sidebar are stripped, and an
        item with neither a `path` nor usable `actions` is dropped — so every
        entry you receive is something the user can actually open.
      properties:
        key:
          type: string
          description: |
            Stable machine key for the item, safe to switch on client-side
            (titles are display copy and may be re-worded). Keys are per-app;
            Ideas emits `dashboard`, `all_ideas`, `campaigns`, `review_queue`;
            Forms emits `my_submissions`, `approvals`; Wikis emits `dashboard`,
            `all_wikis`; Broadcast & Alerts emits `broadcast`, `alert`;
            Communications emits `dashboard`, `feed`, `mail`,
            `my_posts`; Training emits `my_learning`, `catalog`, `my_records`,
            `my_team`; Recognitions emits `dashboard`, `feed`,
            `my_recognition`, `programs`, `awards`, `leaderboard`, `team` (note
            `awards` is the Award Cycles surface — the key matches the web
            sidebar's own tab key); Company Store emits `dashboard`, `catalog`,
            `orders`, `balance`, `approvals` (note `balance` is the Points
            surface — again the web sidebar's own tab key). Apps whose navigation
            comes from the generic sidebar builder use that app's own tab keys.
          example: review_queue
        title:
          type: string
          description: Display label, already localized/worded as the web sidebar
            shows it.
          example: Reviews
        icon:
          type: string
          description: Font Awesome class for the item (same convention as `ConsolidatedApp.icon.url`).
          example: fas fa-scale-balanced
        path:
          type: string
          nullable: true
          description: |
            Path to open for this item. For web callers this is the desktop path
            (e.g. `/apps/ideas/list`); for mobile clients it is the validated
            `/m/...` equivalent — a tab whose desktop path has no real `/m/`
            route is dropped from the array rather than returned with a dead
            link. `null` only on a `native: true` item.
          example: "/apps/ideas/review"
        native:
          type: boolean
          description: |
            Present (and always `true`) only for mobile callers on tabs the
            iOS/Android client renders with its OWN native screen — there is no
            webview route, so `path` is `null` and the client must dispatch on
            `key`. Applies to a fixed set of core-app tabs (Shifts'
            `my_shifts` / `my_availability`, Time & Attendance's
            `my_attendance`, Leave's `my_time_off`, Timesheets'
            `my_timesheets`). Absent on every other item.
          example: true
        count:
          type: integer
          description: |
            Live badge count for the item. Only emitted where the builder
            computes one — today that is the Forms `approvals` item, whose count
            matches exactly what `/forms/approvals` lists for this reviewer
            (whole-business `pending_review` for admin-tier, the manager's own
            reportees' `under_review` submissions for a manager). Treat as
            optional everywhere else.
          example: 3
        actions:
          type: array
          description: |
            Secondary actions nested under the item (the sidebar's per-tab
            dropdown). Dividers/headers are stripped, and for mobile callers any
            action without a valid `/m/` route is removed — so this array can be
            shorter than the web menu, or absent when nothing survived.
          items:
            type: object
            properties:
              title:
                type: string
                example: New idea
              url:
                type: string
                description: Path to open, mobile-converted for mobile callers (same
                  rule as `path`).
                example: "/apps/ideas/new"
              icon:
                type: string
                example: fas fa-plus
    TrainingInfoRows:
      type: array
      description: The "Course Info" / "Path Info" card as ORDERED label/value pairs,
        in the order the web card renders them (Duration, Lessons, Difficulty, Credits,
        Certificate, Self-enroll), followed by the API-only Due and Version rows.
        The row set is tenant- and variant-dependent (an instructor-led course swaps
        the Duration/Lessons pair for Sessions available and adds Attendance), so
        a client renders what it is given rather than hardcoding fields. Blank values
        are omitted entirely; `Self-enroll` is always present and answers Yes / No
        / "No (off in Training settings)" — the EFFECTIVE state, matching the top-level
        `allow_self_enrollment` and the `cta`.
      items:
        type: object
        required:
        - label
        - value
        properties:
          label:
            type: string
            example: Difficulty
          value:
            type: string
            example: Intermediate
    TrainingReviewRow:
      type: object
      description: One approved review on a course/path detail.
      properties:
        id:
          type: integer
          example: 501
        rating:
          type: integer
          minimum: 1
          maximum: 5
          example: 5
        title:
          type: string
          nullable: true
          example: Clearest version I have seen
        content:
          type: string
          nullable: true
          example: I printed page 2 and taped it inside the tool cabinet.
        created_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-24T09:00:00Z'
        mine:
          type: boolean
          description: The caller's own review.
          example: false
        reviewer:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              description: The reviewer's user id.
              example: 41157
            name:
              type: string
              example: Priya Nair
            initials:
              type: string
              nullable: true
              example: PN
            job_title:
              type: string
              nullable: true
              example: Parts Counter
            avatar_url:
              type: string
              description: ABSOLUTE avatar URL. Never null for a real person — a user
                with no profile photo gets a generated ui-avatars.com initials tile
                — so a client can render it unconditionally. `initials` is provided
                too, for a client that prefers to draw its own tile rather than fetch
                one.
              example: https://ui-avatars.com/api/?name=Priya%20Nair&size=40&background=random
    TrainingSessionRow:
      type: object
      description: One instructor-led session. Times are given BOTH in UTC and in
        the session's OWN timezone — the web renders every session in the venue's
        zone, never the viewer's, and a client must be able to do the same.
      properties:
        id:
          type: integer
          example: 90
        title:
          type: string
          nullable: true
          example: Morning cohort
        session_type:
          type: string
          enum:
          - classroom
          - webinar
          - hybrid
          example: classroom
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: The instant, UTC. RENDER IT IN `timezone` — never the device's
            zone.
          example: '2026-08-20T15:00:00Z'
        ends_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-08-20T19:00:00Z'
        timezone:
          type: string
          nullable: true
          example: America/New_York
        timezone_label:
          type: string
          nullable: true
          description: Short zone abbr, or UTC±offset.
          example: EDT
        duration_minutes:
          type: integer
          nullable: true
          example: 240
        location:
          type: string
          nullable: true
          example: Training Room B
        meeting_link:
          type: string
          nullable: true
          example:
        instructor:
          type: object
          nullable: true
          properties:
            name:
              type: string
              example: Maria Lopez
            job_title:
              type: string
              nullable: true
              example: Regional Safety Trainer
        capacity:
          type: object
          properties:
            limit:
              type: integer
              nullable: true
              example: 12
            taken:
              type: integer
              example: 9
            seats_available:
              type: integer
              nullable: true
              example: 3
            full:
              type: boolean
              example: false
            waitlist_count:
              type: integer
              example: 0
        registration:
          type: object
          properties:
            open:
              type: boolean
              description: Scheduled and the deadline has not passed.
              example: true
            closes_at:
              type: string
              format: date-time
              nullable: true
              example: '2026-08-19T15:00:00Z'
        my_registration:
          type: object
          nullable: true
          description: Present only when the caller's seat is on THIS session.
          properties:
            id:
              type: integer
              example: 771
            status:
              type: string
              example: registered
            waitlisted:
              type: boolean
              example: false
            waitlist_position:
              type: integer
              nullable: true
              example:
        cta_action:
          type: string
          description: 'The web row''s precedence: a seat you already hold wins, then
            a closed registration, then a full session (waitlist), then switching,
            then booking. `switch` appears only when the caller''s seat is CHANGEABLE
            (registered or waitlisted) — a session already attended or completed is
            never offered as something to switch away from, since that would discard
            earned attendance.'
          enum:
          - registered
          - waitlisted
          - closed
          - join_waitlist
          - switch
          - register
          example: register
    TrainingLessonRow:
      type: object
      description: One row of a course's lesson outline. Byte-identical whether it
        arrives inlined on the course detail or from the Lessons tab endpoint — both
        serialize the same outline through one serializer, so the tab can never disagree
        with the detail it was opened from.
      properties:
        id:
          type: integer
          example: 91
        title:
          type: string
          example: Lockout/Tagout Procedure
        content_type:
          type: string
          enum:
          - text
          - video
          - document
          - quiz
          - scorm
          - partner_course
          example: video
        type_label:
          type: string
          description: From the canonical TrainingLesson#content_type_label, so the
            app names a lesson exactly as the web page does (e.g. "SCORM", not "Scorm").
          example: Video
        provider:
          type: string
          nullable: true
          description: The vendor behind a `partner_course` lesson — the human-readable
            name the web and `/m/` lesson bodies print above the launch card (e.g.
            "Go1"), so a client can say WHOSE course was taken rather than only that
            it was a partner's. Null for every other `content_type`.
          example:
        type_icon:
          type: string
          example: fa-video
        duration_minutes:
          type: integer
          nullable: true
          description: The AUTHOR'S ESTIMATE, in minutes. For a video this is not
            necessarily the video's length — see `meta_line`.
          example: 12
        meta_line:
          type: string
          nullable: true
          description: |-
            The lesson's one-line label, composed exactly as the web player's eyebrow and the `/m/` reader render it — "Text lesson · 5 min read", "Video lesson · 0:48 watch", "PDF document · 10 min", "SCORM module". RENDER THIS instead of composing your own from `type_label` + `duration_minutes`: a native client drawing its own lesson header is trying to match the other two surfaces, and those two fields cannot reproduce them. `type_label` is the bare type ("Video", not "Video lesson"), and `duration_minutes` is the author's estimate while an uploaded video's REAL length is measured from the file — lesson 479 renders "Video lesson · 0:48 watch" where the raw fields compose to "Video · ~8 min".

            THE DURATION RULE IS PER TYPE and is deliberately server-side, because it is the part that has drifted: ffprobed-length-then-estimate suffixed "watch" for video, the estimate suffixed "read" for text, bare minutes for a document, and NO duration for `scorm` / `quiz` / `partner_course` — those render a launch card whose estimated time is a tile inside the card, not part of this line.

            The raw fields stay for clients that compose their own. Null only when the lesson's type and duration are both unresolvable.
          example: Video lesson · 0:48 watch
        required:
          type: boolean
          example: true
        completed:
          type: boolean
          example: false
        current:
          type: boolean
          description: The lesson the CTA resumes into. At most one row carries this,
            and it is always the same lesson as the detail's `cta.lesson_id`, so a
            client can open the player from either. Never true for a caller with no
            enrollment.
          example: true
        locked:
          type: boolean
          description: True only when the caller holds no enrollment — lessons are
            not sequentially gated within a course.
          example: false
        web_view_url:
          type: string
          nullable: true
          description: |-
            Absolute URL of the BARE mobile render of this lesson (`/m/apps/training/courses/:course_id/lessons/:id`) — the whole ONLINE story for taking a self-paced lesson: load it in a native WebView and the lesson displays with no header, no bottom tab bar, no prev/next and no Mark Complete, because the native screen draws its own and calls `POST …/lessons/{lesson_id}/progress` for the completion.

            ALWAYS CARRIES `?embed=1`, and that param is the ONLY thing that strips the chrome — load this url VERBATIM. Unlike the Wikis reader there is no User-Agent fallback here, deliberately: the existing `/m/` Training pages must keep their chrome for anyone browsing them in a mobile browser, so the bare layout is strictly opt-in. Drop the param and you get the full mobile page inside your native screen.

            Renders every self-paced type: text, video (uploaded or embedded) and document inline; a `scorm` or `partner_course` lesson shows a launch card whose button opens the provider's runtime full-screen and returns here on exit. Null when the serializer had no request context.

            A COURSEWARE LESSON HAS A NATIVE CARD TOO — `GET …/lessons/{lesson_id}/scorm` — and it is the sibling of the quiz card described below, not a lesser surface. It returns the module's status, score, resume sentence/percent, bookmark, launch count, package standard, mastery score, time limit and a resolved `cta`, so a client can draw the whole Status / Score / Estimated time / Resume panel natively; `POST …/lessons/{lesson_id}/scorm/restart` is the "Start over". Only the SCORM/xAPI/cmi5/AICC RUNTIME itself has to stay a WebView (inherent to those standards) — the card around it does not. This paragraph exists because it was missing: the quiz pointer below was here and this one was not, so ISS-20260910-827-69F8A8 was filed asking for courseware status to be added to this very row by an engineer who read the row serializer, found nothing, and never saw the dedicated endpoint next door. Do NOT expect these fields on the row — they are one lesson-addressed call away, deliberately, for the same reason the quiz card is not inlined here.

            A `quiz` LESSON SHOWS A QUIZ CARD, AND ITS BUTTON IS AN INTERCEPTION POINT. The card renders the question count, passing bar, attempt tallies and the learner's standing result, and its Start/Retake button is a plain GET link to `/m/apps/training/courses/{course_id}/lessons/{lesson_id}/quiz/launch`. A native client that wants to run the assessment itself should MATCH THAT PATH in its WebView, cancel the navigation, and drive the quiz endpoints instead — `GET …/lessons/{lesson_id}/quiz` for the same card state, then `POST …/quiz/attempts` and the rest. Reload this url when the native screen closes: passing writes the lesson completion and moves course progress, so the card underneath is stale until you do.

            A client that does NOT intercept needs no special handling — the link is a real route that starts or resumes the attempt and lands the learner in the responsive web player, so leaving it alone is a working flow rather than a dead button. It is a GET for exactly this reason: a POST form is not reliably visible to Android's `shouldOverrideUrlLoading`.
          example: https://officechat.workforce.mangoapps.com/m/apps/training/courses/37/lessons/91?embed=1
    TrainingPathStepRow:
      type: object
      description: |-
        One step of a learning path with its member courses. Identical whether it arrives inlined on the path detail or from the Steps tab endpoint.

        `courses` is ordered by the author's `sort_order` with the join row's id breaking ties: two member courses may legitimately share a sort_order, and without the tiebreak the same step came back in different orders depending on how the association had been preloaded.
      properties:
        id:
          type: integer
          example: 12
        position:
          type: integer
          description: 1-based.
          example: 2
        name:
          type: string
          nullable: true
          example: Core compliance
        courses_count:
          type: integer
          example: 3
        rule:
          type: string
          description: The step's completion rule chip.
          example: All required + 1 of the optional
        complete:
          type: boolean
          example: false
        locked:
          type: boolean
          description: Sequential path AND enrolled caller AND after the first incomplete
            step — all three, so a learner browsing a path they have not joined never
            sees a padlock.
          example: false
        completion_percent:
          type: integer
          description: 0..100 fill for the step's pill — the fraction of the step's
            REQUIRED weight satisfied. Counts COURSES not lessons
          so it is instructor-led-safe.:
          example: 67
        unlocks_after_step:
          type: integer
          nullable: true
          description: Present only when locked — the 1-based step that must finish
            first.
          example:
        courses:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 60
              title:
                type: string
                example: Benefits & Policies
              required:
                type: boolean
                example: true
              delivery_label:
                type: string
                enum:
                - Self-paced
                - Instructor-Led
                - Learning Path
                example: Self-paced
              progress_percentage:
                type: integer
                example: 100
              status:
                type: string
                enum:
                - not_started
                - in_progress
                - completed
                example: completed
              cta_action:
                type: string
                enum:
                - locked
                - review
                - resume
                - start
                - enroll_required
                example: review
    TrainingSubjectBlock:
      type: object
      description: The course or learning path a secondary screen belongs to — a reviews
        or Q&A list, a Lessons or Steps tab — so the client can title the screen without
        a second call. Every one of those endpoints emits it from the one helper (Api::V1::Training::SubjectPayload),
        the same one behind the `subject` block on the My Training cards.
      properties:
        id:
          type: integer
          example: 37
        title:
          type: string
          example: Workplace Safety Fundamentals
        type:
          type: string
          enum:
          - course
          - path
          example: course
        delivery_label:
          type: string
          enum:
          - Self-paced
          - Instructor-Led
          - Learning Path
          example: Self-paced
        category:
          type: object
          nullable: true
          properties:
            name:
              type: string
              example: Compliance
            color:
              type: string
              nullable: true
              example:
            icon:
              type: string
              nullable: true
              example:
    TrainingAnswerRow:
      type: object
      description: One answer inside a Q&A thread. `instructor` drives the INSTRUCTOR
        badge and is true only for a user holding the COURSE's Instructor role (directly
        or through a group) — a learning path has no per-path role, so it is always
        false there, matching the web badge exactly.
      properties:
        id:
          type: integer
          example: 23
        body:
          type: string
          example: Yes. There is no time threshold — if a guard comes off, the machine
            gets locked out.
        votes_count:
          type: integer
          description: Upvotes ("helpful") on this answer.
          example: 12
        is_best_answer:
          type: boolean
          description: At most one per question.
          example: true
        created_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-15T05:54:02Z'
        mine:
          type: boolean
          description: The caller wrote this answer.
          example: false
        my_vote:
          type: boolean
          description: The caller has upvoted it.
          example: false
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              description: The answerer's user id.
              example: 1
            name:
              type: string
              example: Maria Lopez
            initials:
              type: string
              nullable: true
              example: ML
            job_title:
              type: string
              nullable: true
              example: Regional Safety Trainer
            avatar_url:
              type: string
              description: ABSOLUTE avatar URL. Never null for a real person — a user
                with no profile photo gets a generated ui-avatars.com initials tile
                — so a client can render it unconditionally. `initials` is provided
                too, for a client that prefers to draw its own tile rather than fetch
                one.
              example: https://ui-avatars.com/api/?name=Priya%20Nair&size=40&background=random
            instructor:
              type: boolean
              example: true
    TrainingQuestionRow:
      type: object
      description: |-
        One question thread — the question plus its answers, mirroring one card of the web Q&A tab.
        `status` is what the client renders the waiting state from: an `unanswered` question shows the "waiting on your instructor" strip instead of an answer block.
        `best_answer` is a CONVENIENCE POINTER, not a second copy — it is the same object already present in `answers` (which is ordered best-answer first, then most upvoted, the web's exact sort), so a client with room for one answer reads it directly. It is null until someone marks a best answer, and a question can be `answered` with no best answer.
      properties:
        id:
          type: integer
          example: 30
        body:
          type: string
          example: Do I need lockout/tagout for a two-minute belt change?
        status:
          type: string
          enum:
          - unanswered
          - answered
          example: answered
        votes_count:
          type: integer
          description: Upvotes ("helpful") on the question.
          example: 17
        answers_count:
          type: integer
          example: 2
        created_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-15T05:54:02Z'
        mine:
          type: boolean
          description: The caller asked this.
          example: true
        my_vote:
          type: boolean
          description: The caller has upvoted it.
          example: false
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              description: The asker's user id.
              example: 41157
            name:
              type: string
              example: Priya Nair
            initials:
              type: string
              nullable: true
              example: PN
            job_title:
              type: string
              nullable: true
              example: Parts Counter
            avatar_url:
              type: string
              description: ABSOLUTE avatar URL. Never null for a real person — a user
                with no profile photo gets a generated ui-avatars.com initials tile
                — so a client can render it unconditionally. `initials` is provided
                too, for a client that prefers to draw its own tile rather than fetch
                one.
              example: https://ui-avatars.com/api/?name=Priya%20Nair&size=40&background=random
        best_answer:
          allOf:
          - "$ref": "#/components/schemas/TrainingAnswerRow"
          nullable: true
        answers:
          type: array
          items:
            "$ref": "#/components/schemas/TrainingAnswerRow"
    TrainingFeedbackPageMeta:
      type: object
      description: Pagination over the list (build_pagination_meta).
      properties:
        total_count:
          type: integer
          example: 25
        total_pages:
          type: integer
          example: 2
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          example: true
        has_prev_page:
          type: boolean
          example: false
    TrainingReviewsPage:
      type: object
      description: The full Ratings & Reviews list for a course or a learning path
        — one payload, served by both nested routes.
      required:
      - subject
      - rating
      - reviews
      - meta
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        rating:
          type: object
          description: The histogram header. APPROVED reviews only, and identical
            to the `rating` block on the course/path detail (both read HasReviews).
          properties:
            average:
              type: number
              nullable: true
              description: null when there are no approved reviews.
              example: 3.5
            count:
              type: integer
              example: 2
            distribution:
              type: object
              description: Star -> count, for the 5..1 histogram. SPARSE — a star
                with no reviews is ABSENT, not zero, so treat a missing key as 0.
                (Same shape the detail endpoints return.)
              additionalProperties:
                type: integer
              example:
                '5': 1
                '2': 1
        active_sort:
          type: string
          description: The sort actually applied — an unrecognised `sort` falls back
            to `recent`. `helpful` is deliberately NOT a sort here (the Helpful affordance
            is admin-only on the web), so `sort=helpful` is one of the unrecognised
            values and comes back as `recent`.
          enum:
          - recent
          - lowest
          example: recent
        reviews:
          type: array
          description: The requested page. The caller's OWN review is included here
            as well as in `my_review` (matching the detail endpoints); `mine` flags
            it.
          items:
            "$ref": "#/components/schemas/TrainingReviewRow"
        my_review:
          allOf:
          - "$ref": "#/components/schemas/TrainingReviewRow"
          nullable: true
          description: The caller's own review whether or not it falls on this page
            — the sheet's CTA reads "Edit your review" vs "Rate this course" off it.
            Null when they have not reviewed. Unlike `reviews`, this is NOT filtered
            to approved, so an author still sees a review awaiting moderation.
        can_review:
          type: boolean
          description: Enrolled AND has not already reviewed (TrainingReview.user_can_review?
            — the same rule the web gate applies). False once `my_review` is set.
          example: true
        meta:
          "$ref": "#/components/schemas/TrainingFeedbackPageMeta"
    TrainingQuestionsPage:
      type: object
      description: The Q&A thread list for a course or a learning path — one payload,
        served by both nested routes.
      required:
      - subject
      - counts
      - questions
      - meta
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        active_filter:
          type: string
          description: The filter actually applied — an unrecognised `filter` falls
            back to `all`.
          enum:
          - all
          - unanswered
          - top
          example: all
        counts:
          type: object
          description: FILTER-INDEPENDENT tallies, so all three pills can be badged
            from one request. `answered` is derived (`total - unanswered`), because
            the two statuses partition the set.
          properties:
            total:
              type: integer
              example: 25
            answered:
              type: integer
              example: 9
            unanswered:
              type: integer
              example: 16
        questions:
          type: array
          items:
            "$ref": "#/components/schemas/TrainingQuestionRow"
        permissions:
          type: object
          description: What this caller may do from the screen. Both are true for
            anyone who got a 200 — asking and answering carry no per-owner gate on
            the web either, so app access is the whole rule. Marking a best answer
            is content-manager-only and has no mobile surface, so it is deliberately
            not reported.
          properties:
            can_ask:
              type: boolean
              example: true
            can_answer:
              type: boolean
              example: true
            can_mark_best:
              type: boolean
              description: 'May the caller mark a best answer on this owner — the
                ONE Q&A action with a per-owner permission (a Training admin, the
                COURSE''s Instructor, or a learning path''s creator). Render the Mark-best
                affordance from THIS flag: `POST /training/qa/answers/{id}/mark_best`
                exists for every caller and answers 403 `forbidden` for a learner,
                so keying the button on the endpoint''s existence shows it to everyone.'
              example: false
        meta:
          "$ref": "#/components/schemas/TrainingFeedbackPageMeta"
    TrainingTeamStats:
      type: object
      description: A team's headline metrics, counted off ONE relation so they cannot
        disagree with each other. `.current` enrollments only, so a learner who retook
        a course is counted once.
      properties:
        team_size:
          type: integer
          description: Headcount in scope (the TRUE total
          not the page size).:
          example: 142
        total_enrollments:
          type: integer
          example: 119
        completed:
          type: integer
          example: 39
        in_progress:
          type: integer
          example: 31
        overdue:
          type: integer
          example: 17
        not_started:
          type: integer
          example: 48
        completion_rate:
          type: number
          description: Percent, 1dp. 0 (never null) for a team with no enrollments.
          example: 32.8
        avg_progress:
          type: number
          description: Mean progress percentage across current enrollments, 1dp. A
            NUMBER, not a string — Postgres AVG returns a BigDecimal and is cast before
            encoding.
          example: 42.6
    TrainingTeamMemberRow:
      type: object
      description: 'One learner card on the roster. Counts ship raw so the client
        owns the presentation: the progress bar is `done / total`, the caption is
        "N of M complete", and both the bar colour and the card''s alert border key
        off `overdue > 0`.'
      properties:
        id:
          type: integer
          example: 41157
        name:
          type: string
          example: Priya Raman
        initials:
          type: string
          nullable: true
          example: PR
        title:
          type: string
          nullable: true
          description: Job title in this business.
          example: Service Advisor
        avatar_url:
          type: string
          description: ABSOLUTE avatar URL; never null (falls back to a generated
            initials tile).
          example: https://ui-avatars.com/api/?name=Priya%20Raman&size=40&background=random
        total:
          type: integer
          description: Current enrollments.
          example: 7
        done:
          type: integer
          example: 5
        overdue:
          type: integer
          example: 2
    TrainingTeamEnrollmentRow:
      type: object
      description: |-
        One enrollment row inside My Team. Raw values, no pre-composed sentences — the client builds "Due 12 Aug · 40% done" / "3 days overdue" itself, so the copy stays translatable.
        `learner` is present only where the row appears OUT of a learner's context (the dashboard's overdue list, which spans the team); the member drill-in omits it because its header already names the person.
      properties:
        enrollment_id:
          type: integer
          description: Pass this to the remind endpoint.
          example: 22989
        course_id:
          type: integer
          nullable: true
          example: 128
        title:
          type: string
          nullable: true
          example: Forklift Operator Certification
        status:
          type: string
          example: enrolled
        progress:
          type: integer
          description: Percent complete
          0..100.:
          example: 40
        overdue:
          type: boolean
          example: true
        days_overdue:
          type: integer
          nullable: true
          description: null when not overdue — 0 would read as "due today".
          example: 25
        due_date:
          type: string
          format: date-time
          nullable: true
          example: '2026-08-06T00:00:00Z'
        completed_at:
          type: string
          format: date-time
          nullable: true
          example:
        learner:
          type: object
          nullable: true
          description: Present on the dashboard's overdue rows only.
          properties:
            id:
              type: integer
              example: 1257
            name:
              type: string
              example: Melanie Kirkwood
            initials:
              type: string
              nullable: true
              example: MK
            title:
              type: string
              nullable: true
              example: Shift Lead
            avatar_url:
              type: string
              example: https://ui-avatars.com/api/?name=Melanie%20Kirkwood&size=40&background=random
    TrainingMyTeamDashboard:
      type: object
      description: The My Team dashboard for ONE tab. `members`, `overdue` and `completions`
        always all appear and only the tab named by `active_tab` carries rows — stable
        key types for a generated client, at the cost of two empty arrays. `stats`
        and `pills` are tab-independent; `meta` paginates the ACTIVE tab.
      required:
      - scope
      - active_tab
      - stats
      - pills
      - members
      - overdue
      - completions
      - meta
      properties:
        active_tab:
          type: string
          description: The tab actually applied — an unrecognised `tab` falls back
            to `people`. Tells the client which of the three list keys to read.
          enum:
          - people
          - overdue
          - done
          example: people
        scope:
          type: object
          description: 'Who the caller is looking at. `total` and `per_page` ship
            separately from the page so the client can compose its own "Showing N
            of M" caption rather than parse a sentence, and `admin_view` tells it
            whether to say "Employees" or "Team". `key` + `available` drive the Team
            Scope segmented control: render it only when `available` holds more than
            one value, and select `key`.'
          properties:
            key:
              type: string
              description: The scope actually applied. An admin defaults to `all_employees`
                and may narrow to `direct_reports`; a manager is always `direct_reports`.
              enum:
              - direct_reports
              - all_employees
              example: all_employees
            available:
              type: array
              description: What THIS caller may request via `?scope=`. Both values
                for a Training admin, `["direct_reports"]` alone for a people-manager
                — the role is a ceiling, so a manager requesting `all_employees` is
                still served their own reports.
              items:
                type: string
                enum:
                - direct_reports
                - all_employees
              example:
              - direct_reports
              - all_employees
            admin_view:
              type: boolean
              description: true = these rows are every active member of the business.
                Derived from the APPLIED scope rather than the role, so an admin who
                has narrowed to `direct_reports` reads false and labels the roster
                "Team".
              example: true
            total:
              type: integer
              example: 142
            per_page:
              type: integer
              example: 20
            capped:
              type: boolean
              description: true when the roster is larger than one page.
              example: true
        stats:
          "$ref": "#/components/schemas/TrainingTeamStats"
        pills:
          type: array
          description: The three filter pills with the counts that badge them. Keys
            are stable (`people`, `overdue`, `done`).
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - people
                - overdue
                - done
                example: overdue
              count:
                type: integer
                example: 17
        members:
          type: array
          description: The roster page, ordered by first then last name. Populated
            only when `active_tab` is `people`; otherwise an empty array.
          items:
            "$ref": "#/components/schemas/TrainingTeamMemberRow"
        overdue:
          type: array
          description: Overdue rows across the WHOLE team (not just the roster page
            — the pill counts the team, so its list must too), soonest-due first and
            PAGINATED. Each row names its learner. Populated only when `active_tab`
            is `overdue`.
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        completions:
          type: array
          description: Most recent completions across the whole team, PAGINATED. Populated
            only when `active_tab` is `done`.
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        meta:
          allOf:
          - "$ref": "#/components/schemas/TrainingFeedbackPageMeta"
          description: Pagination for the ACTIVE tab — so `total_count` is the roster
            size on `people`, the overdue count on `overdue`, and the completed count
            on `done`.
    TrainingMyTeamMember:
      type: object
      required:
      - member
      - counts
      - open
      - completed
      - certificates
      properties:
        member:
          "$ref": "#/components/schemas/TrainingTeamMemberRow"
        counts:
          type: object
          description: "`assigned` vs `in_progress` splits on PROGRESS, not status:
            a learner who opened a course but finished nothing counts as assigned."
          properties:
            assigned:
              type: integer
              example: 2
            in_progress:
              type: integer
              example: 3
            completed:
              type: integer
              example: 5
            overdue:
              type: integer
              example: 1
        open:
          type: array
          description: Everything outstanding, newest first. `learner` is omitted
            (the header names them).
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        completed:
          type: array
          description: The completed history, newest first.
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        certificates:
          type: array
          description: Issued certificates, newest first, capped at 12.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 64
              title:
                type: string
                nullable: true
                example: Workplace Safety Fundamentals
              issued_at:
                type: string
                format: date-time
                nullable: true
                example: '2026-07-24T09:00:00Z'
    TrainingReviewCreateRequest:
      type: object
      description: |-
        Body of a review POST (a course or a learning path — same shape for both).
        Every field must be a SINGLE SCALAR. A collection shape (`rating[]=5`, `title[a]=x`) is read as ABSENT rather than coerced — so a non-scalar `rating` fails its presence validation into the normal per-field 422, and a non-scalar `title`/`content` is stored as null instead of as the collection's own string form.
      required:
      - rating
      properties:
        rating:
          type: integer
          description: Whole stars, 1..5. Missing or out of range answers 422 with
            a per-field error.
          minimum: 1
          maximum: 5
          example: 5
        title:
          type: string
          nullable: true
          maxLength: 255
          example: Genuinely useful
        content:
          type: string
          nullable: true
          maxLength: 5000
          example: The lockout/tagout walkthrough matched our floor exactly.
    TrainingReviewUpdateRequest:
      type: object
      description: |-
        Body of a review PATCH. Every field is OPTIONAL and applied only when the key is PRESENT, so a client can change the stars without resending prose it is not touching — omitting `title` leaves the stored title alone, whereas sending it blank clears it.
        At least one of the three must be present; an empty body is 422 `nothing_to_update` rather than a silent no-op.
        Same single-scalar rule as the create request: a collection shape is read as ABSENT, so `title[]=x` clears the title rather than storing the collection's own string form.
      minProperties: 1
      properties:
        rating:
          type: integer
          description: Whole stars, 1..5. Sending it blank or out of range is 422
            with a per-field error — it does NOT fall back to the stored value.
          minimum: 1
          maximum: 5
          example: 4
        title:
          type: string
          nullable: true
          maxLength: 255
          description: Send blank to clear.
          example: Better on a second pass
        content:
          type: string
          nullable: true
          maxLength: 5000
          description: Send blank to clear.
          example: Revisited after the refresher — the LOTO section is the strongest
            part.
    TrainingReviewWriteResult:
      type: object
      description: |-
        The payload of EITHER review write — the 201 from a POST or the 200 from a PATCH. One shape, because a client refreshes the same summary block after either. It carries the RECOMPUTED `rating` histogram (never the pre-write one), which is the whole reason to read this response instead of patching a cached row: editing the stars moves the histogram.
        (Named `TrainingReviewCreated` until the edit endpoint shipped; renamed because the shape was never create-specific.)
      required:
      - subject
      - rating
      - review
      - can_review
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        rating:
          type: object
          description: The histogram header including the review just posted — identical
            in shape to the `rating` block on the reviews list and the course/path
            detail (all three read HasReviews#rating_summary).
          properties:
            average:
              type: number
              nullable: true
              example: 4.7
            count:
              type: integer
              example: 3
            distribution:
              type: object
              description: Star -> count. SPARSE — treat a missing key as 0.
              additionalProperties:
                type: integer
              example:
                '5': 2
                '4': 1
        review:
          "$ref": "#/components/schemas/TrainingReviewRow"
        can_review:
          type: boolean
          description: Always false here — the caller has just used their one review.
            Same key the reviews GET returns, so the CTA flips to "Edit your review"
            off this response.
          example: false
    TrainingQaPostRequest:
      type: object
      description: 'Body of the Q&A POST. `question_id` is what selects between the
        endpoint''s two behaviours: absent asks a new question, present posts an answer
        (a "reply") to that question.'
      required:
      - body
      properties:
        body:
          type: string
          description: The question or answer text. Must be a SINGLE SCALAR — whitespace-only
            AND any collection shape (`body[]=x`, `body[a]=x`) are rejected 422 `body_required`,
            so a malformed body can never be stored as the collection's own string
            form. Capped at 2000 characters for a question and 5000 for an answer
            (model validations — an over-long body answers 422 with a per-field error).
          example: Do I need lockout/tagout for a two-minute belt change?
        question_id:
          type: integer
          nullable: true
          description: |-
            Omit to ASK. Pass a question id to REPLY to it. Must name a question on THIS course / learning path — anything else is 422 `question_not_found`, never a misfiled answer. There is no reply-to-a-reply: an answer has no parent answer.
            Must be a SINGLE SCALAR. A collection shape (`question_id[]=30`) is a malformed reply TARGET and answers 422 `question_not_found` — it does NOT degrade into asking a new question, which would file the text somewhere the caller never asked for.
          example: 30
    TrainingQaThread:
      type: object
      description: ONE Q&A thread and its context — the payload of every Q&A WRITE
        (the ask/reply POST and mark_best), so a client patches its card the same
        way whatever it just did. `question` is the exact row shape the Q&A list returns,
        re-read after the write, so the thread already reflects the new answer, the
        re-sorted `answers` and any `status` change.
      required:
      - subject
      - question
      - counts
      - permissions
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        question:
          "$ref": "#/components/schemas/TrainingQuestionRow"
        answer_id:
          type: integer
          nullable: true
          description: The answer row this call created or marked, so a client can
            highlight it without diffing. Null when a new QUESTION was asked.
          example: 23
        counts:
          type: object
          description: The owner's post-write Q&A tallies, in the same shape and the
            same keys the Q&A list returns, so a client updates its pill badges without
            re-fetching.
          properties:
            total:
              type: integer
              example: 26
            answered:
              type: integer
              example: 10
            unanswered:
              type: integer
              example: 16
        permissions:
          type: object
          description: Same block, and the same rules, as the Q&A list's `permissions`.
          properties:
            can_ask:
              type: boolean
              example: true
            can_answer:
              type: boolean
              example: true
            can_mark_best:
              type: boolean
              example: false
    TrainingQaVoteState:
      type: object
      description: A question's or answer's upvote state AFTER a toggle, read back
        from the database. The toggle is NOT idempotent, so these are the only trustworthy
        values — do not predict them client-side.
      required:
      - type
      - id
      - votes_count
      - my_vote
      properties:
        type:
          type: string
          description: Which kind of row this is. SINGULAR, while the route segment
            is plural (`questions`/`answers`) — this API names a kind in a body singularly
            and in a path plurally throughout.
          enum:
          - question
          - answer
          example: answer
        id:
          type: integer
          description: The question's or answer's own id.
          example: 23
        votes_count:
          type: integer
          description: The row's upvote total after the toggle.
          example: 13
        my_vote:
          type: boolean
          description: Whether the caller now holds an upvote on it.
          example: true
        question_id:
          type: integer
          description: Present for an ANSWER only — the thread the row belongs to,
            so a client can locate the card without holding the mapping itself.
          example: 30
    TrainingDetailCta:
      type: object
      description: The screen's primary action, following the web sidebar / registration-card
        ladder. `lesson_id` is the lesson a resume/start lands on, so the client needs
        no second call to open the player — it is always the row flagged `current`
        in the returned `lessons[]`, never a lesson outside that outline.
      required:
      - action
      properties:
        action:
          type: string
          enum:
          - resume
          - start
          - review
          - view_certificate
          - enroll
          - buy
          - choose_session
          - view_session
          - start_path
          - continue_step
          - view_steps
          - locked
          example: resume
        lesson_id:
          type: integer
          nullable: true
          description: resume/start only.
          example: 130
        quiz:
          type: boolean
          description: resume/start only — the target lesson is a quiz.
          example: false
        certificate_id:
          type: integer
          nullable: true
          description: view_certificate only.
          example: 64
        price:
          type: string
          nullable: true
          description: buy only.
          example: "$49.00"
        step_position:
          type: integer
          nullable: true
          description: continue_step only (1-based).
          example: 2
        reason:
          type: string
          nullable: true
          description: locked only. `admin_enrolls` — self-enrollment is not offered
            for this item, an administrator enrolls learners. `purchase_required`
            — a priced course on a tenant that does not sell courses natively (the
            price still rides in the payload's own `price` / `free`), so asking an
            admin is the wrong instruction. Both are values the course detail sends;
            listed in full because a closed enum in a generated client fails the whole
            response on an unknown one.
          enum:
          - admin_enrolls
          - purchase_required
          example: admin_enrolls
    UnreadNotificationCount:
      type: integer
      description: |
        **Piggyback field, returned by EVERY endpoint** that renders through the
        API base controller's `render_with_piggyback` — which is effectively all
        of `/api/v1` (including every Ideas endpoint, `/apps`, `/home`, and the
        rest). It is documented once here and `$ref`-ed where relevant rather
        than repeated per endpoint.

        The value is the calling user's count of unread, active notifications in
        the current business — the number native clients paint on the app badge,
        which is why it rides along on unrelated responses instead of forcing a
        separate `/notifications/count` call.

        Gotchas:

        * Present only when the request resolved BOTH a user and a business; an
          unauthenticated/business-less response omits it entirely.
        * Degrades to `0` (never an error) if the count query fails.
        * It is a snapshot at response time — do not treat it as a delta, and do
          not assume it reflects any notification created by the same request.
      example: 3
    IdeaLifecycleStage:
      type: object
      description: |
        One Ideas lifecycle stage. Emitted identically wherever a stage appears —
        the ordered pipeline in `GET /ideas/config` and the stage reported by
        `PATCH /ideas/{idea_id}/stage` — because both render the same shared
        payload, so one parser handles both.
      required:
      - id
      - name
      - category
      - color
      - icon
      - position
      properties:
        id:
          type: integer
          description: Stable across a rename — safe to cache and to match against
            an idea's `stage.id`.
          example: 412
        name:
          type: string
          description: The admin-editable stage label.
          example: Reviewing
        category:
          type: string
          enum:
          - entry
          - active
          - implemented
          - declined
          description: What outcome metrics key off — never the id. Exactly one stage
            is `entry` (where new ideas land); `implemented` / `declined` are terminal
            outcomes, and moving INTO a `declined` stage records the off-ramp.
          example: active
        color:
          type: string
          description: Hex color for the stage pill.
          example: "#997404"
        icon:
          type: string
          description: |-
            Font Awesome glyph name with **no `fa-` prefix** (the same convention as `campaign.icon` and `vote_icon`), so prefix it the way you prefix those.
            DERIVED, not stored — there is no icon column and admins never pick one. The stage's LABEL wins when it is one of the two the mockup specifies (`Reviewing` → `magnifying-glass`, `Planned` → `calendar`); otherwise the CATEGORY decides: `entry` → `inbox`, `implemented` → `circle-check`, `declined` → `circle-xmark`, and `active` (or anything unrecognised) → `circle-dot`. Renaming or re-categorising a stage therefore changes its icon, and it is always the same glyph the web renders for that stage.
          example: magnifying-glass
        position:
          type: integer
          description: 0-based pipeline position.
          example: 1
    IdeasAudience:
      type: object
      description: |
        An Ideas audience setting — who a capability is open to. Used by
        `submit_audience` (who can submit ideas) and `campaign_creators` (who can
        create campaigns) in `GET /ideas/config`.

        `type: "all"` means everyone in the business. `type: "group"` means only
        members of `group`. `group` is `null` when the saved group has since been
        deleted or belongs to another business — the runtime gate DENIES in that
        case, so always drive affordances off the paired `can_*` boolean rather
        than inferring permission from `type`.
      required:
      - value
      - type
      - group
      properties:
        value:
          type: string
          description: The raw saved setting — `"all"`, or the group id as a string.
          example: all
        type:
          type: string
          enum:
          - all
          - group
          example: all
        group:
          type: object
          nullable: true
          description: 'The audience group; null for `type: "all"`, or when the saved
            group no longer resolves.'
          required:
          - id
          - name
          properties:
            id:
              type: integer
              example: 44387
            name:
              type: string
              example: Idea Submitters
    IdeasReviewPanel:
      type: object
      description: |
        An Ideas review panel — the group whose members score ideas and move them
        through the lifecycle. Used by `idea_reviewers` and `campaign_reviewers` in
        `GET /ideas/config`.

        `count` is the panel's FULL membership size. This node **names nobody** —
        there is no `members` key, and the shape is identical whatever
        `reviewer_names_visible` says. Render "Reviewed by <group.name> (<count>)"
        from it, and call a roster endpoint — both searchable, paginated, and gated on
        `reviewer_names_visible` — when you need the actual people:
        `GET /ideas/{idea_id}/reviewers` for a specific idea's panel, and
        `GET /ideas/campaigns/{id}/reviewers` for a specific campaign's.
      required:
      - group
      - count
      - source
      properties:
        group:
          type: object
          nullable: true
          description: The panel group; null only when nothing resolves (no saved
            group and no All Admins fallback), in which case `count` is 0.
          required:
          - id
          - name
          properties:
            id:
              type: integer
              example: 44390
            name:
              type: string
              example: Idea Reviewers
        count:
          type: integer
          description: The panel's full member count (not the size of `members`).
          example: 8
        source:
          type: string
          enum:
          - configured
          - fallback
          - inherited
          description: |
            Where the panel came from:
            * `configured` — an admin picked this group in Settings.
            * `fallback` — no group is saved, so ideas route to the built-in
              **All Admins** group (`idea_reviewers` only).
            * `inherited` — no campaign panel is saved, so a new campaign mirrors
              `idea_reviewers` (`campaign_reviewers` only).
          example: configured
    FormSummary:
      type: object
      description: Summary information for a form template
      properties:
        id:
          type: integer
          description: Unique form template ID
          example: 123
        name:
          type: string
          description: Form name
          example: Incident Report
        description:
          type: string
          description: Form description
          example: Report workplace incidents and safety concerns
        category:
          type: string
          description: Form category
          example: safety
        priority:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          description: Form priority level
          example: high
        status:
          type: string
          enum:
          - draft
          - published
          - archived
          description: Template status
          example: published
        field_count:
          type: integer
          description: Total number of fields in the form
          example: 8
        required_field_count:
          type: integer
          description: Number of required fields
          example: 5
        estimated_time_minutes:
          type: integer
          description: Estimated completion time in minutes
          example: 10
        has_file_uploads:
          type: boolean
          description: Whether the form supports file uploads
          example: true
        sharing_enabled:
          type: boolean
          description: Whether the form can be shared publicly
          example: false
        requires_approval:
          type: boolean
          description: Whether submissions require approval
          example: true
        created_at:
          type: string
          format: date-time
          description: Form creation timestamp
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: Last update timestamp
          example: '2024-01-15T10:00:00Z'
      required:
      - id
      - name
      - category
      - priority
      - status
      - field_count
      - estimated_time_minutes
    FormTemplate:
      type: object
      description: Form data optimized for rendering
      properties:
        template:
          "$ref": "#/components/schemas/FormTemplate"
        draft_submission:
          "$ref": "#/components/schemas/FormSubmission"
          nullable: true
          description: 'Existing draft submission for progress restoration. NOTE:
            the `submission_data` property is intentionally omitted here — each field
            in `fields[]` now carries its own `submission_data` (sourced from this
            draft or the requested submission), so the map is not repeated on this
            object.'
        mobile_config:
          type: object
          description: Mobile-specific rendering configuration
          properties:
            offline_capable:
              type: boolean
              description: Whether the form supports offline mode
              example: true
            voice_input_enabled:
              type: boolean
              description: Whether voice input is available
              example: true
            photo_capture_enabled:
              type: boolean
              description: Whether photo capture is available
              example: true
            gps_enabled:
              type: boolean
              description: Whether GPS location is required
              example: false
            estimated_time_minutes:
              type: integer
              description: Estimated completion time
              example: 10
        prefill_data:
          type: object
          description: Pre-filled field values
          nullable: true
          additionalProperties: true
      required:
      - template
      - mobile_config
    FormField:
      type: object
      description: Individual form field definition
      properties:
        id:
          type: integer
          description: Unique field ID
          example: 456
        field_name:
          type: string
          description: Internal field name (used for data storage)
          example: incident_type
        field_type:
          type: string
          enum:
          - text
          - textarea
          - number
          - email
          - phone
          - url
          - select
          - multiselect
          - checkbox
          - radio
          - date
          - datetime
          - time
          - file
          - image
          - video
          - audio
          - signature
          - location
          - gps
          - barcode
          - qr_code
          description: Field input type
          example: select
        label:
          type: string
          description: Field label for display
          example: Incident Type
        is_editable:
          type: boolean
          description: Whether the client should render this field as user-editable.
            False for externally-sourced/computed inputs (lookup, rest_api), the table
            layout block, and navigation-control fields (conditional_navigator, flow_controller,
            smart_button, progress_gate, page_jump, section_toggle); true for all
            other field types.
          example: true
        description:
          type: string
          description: Help text for the field
          nullable: true
          example: Select the type of incident that occurred
        placeholder:
          type: string
          description: Placeholder text
          nullable: true
          example: Choose incident type...
        required:
          type: boolean
          description: Whether the field is required
          example: true
        position:
          type: integer
          description: Field order position
          example: 1
        section:
          type: string
          description: Form section grouping
          nullable: true
          example: incident_details
        options:
          type: array
          description: Options for select/radio/checkbox fields using rich object
            format
          nullable: true
          items:
            type: object
            properties:
              value:
                type: string
                description: Option value stored in database
                example: injury
              label:
                type: string
                description: Option display label shown to users
                example: Injury
              description:
                type: string
                description: Optional description for the option
                nullable: true
            required:
            - value
            - label
        validation_rules:
          type: object
          description: Field validation rules
          nullable: true
          properties:
            required:
              type: boolean
              example: true
            min_length:
              type: integer
              example: 10
            max_length:
              type: integer
              example: 500
            min_value:
              type: number
              example: 0
            max_value:
              type: number
              example: 100
            pattern:
              type: string
              example: "^[A-Za-z0-9]+$"
            unique:
              type: boolean
              description: '"Unique values only" — when true, the same value may not
                be submitted twice for this field. Present only when enabled.'
              example: true
            custom_message:
              type: string
              description: Custom validation message to show instead of the generic
                one when this field fails validation. Present only when configured.
              example: Please enter a valid employee ID.
        conditional_logic:
          type: object
          description: Field conditional display logic
          nullable: true
          properties:
            show_if:
              type: object
              properties:
                field:
                  type: string
                  description: Field name to check
                operator:
                  type: string
                  enum:
                  - equals
                  - not_equals
                  - contains
                  - greater_than
                  - less_than
                value:
                  type: string
                  description: Value to compare against
        configuration:
          type: object
          description: Field-specific configuration settings
          nullable: true
          additionalProperties: true
        submission_data:
          nullable: true
          description: 'This field''s saved value, ALWAYS present on GET /api/v1/forms/:id.
            Source: the submission named by `submission_id` (prefill for resume/edit)
            when supplied, otherwise the caller''s DRAFT submission for this form.
            `null` when there is no such submission/draft data, or when the referenced
            submission/draft never answered this field. Type matches the field (string,
            number, array for galleries, object for file references). For media fields
            (file/image/video/audio/gallery/signature) each file reference is enriched
            with a resolved absolute `url` (alongside its `file_id`) so the client
            can render/download the previously uploaded file — a single object for
            file/image/video/audio/signature, an array of such objects for a gallery.'
      required:
      - id
      - field_name
      - field_type
      - label
      - required
      - position
      - submission_data
    CompensationProfile:
      type: object
      description: Employee compensation profile with current details and policies
      properties:
        id:
          type: integer
          description: User business ID
          example: 123
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            title:
              type: string
              nullable: true
              example: Software Engineer
            hire_date:
              type: string
              format: date
              nullable: true
              example: '2023-01-15'
        current_compensation:
          type: object
          properties:
            type:
              type: string
              enum:
              - salary
              - hourly
              - commission
              - contract
              example: salary
            annual_salary:
              type: number
              nullable: true
              example: 75000.0
            hourly_rate:
              type: number
              nullable: true
              example: 36.06
            currency:
              type: string
              example: USD
            pay_frequency:
              type: string
              enum:
              - weekly
              - biweekly
              - monthly
              - quarterly
              - annually
              example: monthly
            effective_date:
              type: string
              format: date
              nullable: true
              example: '2024-01-01'
        policies:
          type: object
          properties:
            can_view_details:
              type: boolean
              example: true
            can_request_changes:
              type: boolean
              example: true
            request_frequency:
              type: string
              enum:
              - annual
              - semi_annual
              - unlimited
              example: annual
            mobile_access_enabled:
              type: boolean
              example: true
        next_review_eligible:
          type: string
          format: date
          nullable: true
          example: '2024-12-01'
        last_updated:
          type: object
          properties:
            date:
              type: string
              format: date-time
              example: '2024-01-15T10:30:00Z'
            by:
              type: string
              example: System
    CompensationHistoryRecord:
      type: object
      description: Individual compensation change record
      properties:
        id:
          type: integer
          example: 789
        change_date:
          type: string
          format: date-time
          example: '2024-01-15T10:30:00Z'
        effective_date:
          type: string
          format: date
          example: '2024-02-01'
        change_reason:
          type: string
          example: Annual merit increase
        changed_by:
          type: object
          properties:
            id:
              type: integer
              example: 101
            name:
              type: string
              example: Jane Manager
        changes:
          type: object
          properties:
            compensation_type:
              type: object
              properties:
                from:
                  type: string
                  nullable: true
                  example: salary
                to:
                  type: string
                  nullable: true
                  example: salary
            annual_salary:
              type: object
              properties:
                from:
                  type: number
                  nullable: true
                  example: 70000.0
                to:
                  type: number
                  nullable: true
                  example: 75000.0
            hourly_rate:
              type: object
              properties:
                from:
                  type: number
                  nullable: true
                  example:
                to:
                  type: number
                  nullable: true
                  example:
            pay_frequency:
              type: object
              properties:
                from:
                  type: string
                  nullable: true
                  example: monthly
                to:
                  type: string
                  nullable: true
                  example: monthly
            currency:
              type: object
              properties:
                from:
                  type: string
                  nullable: true
                  example: USD
                to:
                  type: string
                  nullable: true
                  example: USD
        change_summary:
          type: string
          example: 'Salary: $70,000 → $75,000'
        change_direction:
          type: string
          enum:
          - increase
          - decrease
          - neutral
          example: increase
        is_retroactive:
          type: boolean
          example: false
        effective_soon:
          type: boolean
          example: true
    CompensationInsights:
      type: object
      description: Personalized compensation insights and analytics
      properties:
        summary:
          type: object
          properties:
            current_annualized_value:
              type: number
              nullable: true
              example: 75000.0
            compensation_type:
              type: string
              example: salary
            currency:
              type: string
              example: USD
            tenure_months:
              type: integer
              example: 18
        recent_activity:
          type: object
          properties:
            changes_last_12_months:
              type: integer
              example: 1
            last_change_date:
              type: string
              format: date
              nullable: true
              example: '2024-01-15'
        request_eligibility:
          type: object
          properties:
            can_request_now:
              type: boolean
              example: true
            next_eligible_date:
              type: string
              format: date
              nullable: true
              example: '2025-01-15'
        performance_link:
          type: object
          properties:
            has_recent_review:
              type: boolean
              example: false
            merit_increase_eligible:
              type: boolean
              example: false
    CompensationCalculation:
      type: object
      description: Compensation calculation result
      properties:
        compensation_type:
          type: string
          enum:
          - salary
          - hourly
          example: salary
        annual_salary:
          type: number
          nullable: true
          example: 75000.0
        calculated_hourly_rate:
          type: number
          nullable: true
          example: 36.06
        hourly_rate:
          type: number
          nullable: true
          example:
        calculated_annual_salary:
          type: number
          nullable: true
          example:
        annual_hours_basis:
          type: number
          example: 2080.0
        currency:
          type: string
          example: USD
    CompensationRequestSummary:
      type: object
      description: Summary view of compensation request
      properties:
        id:
          type: integer
          example: 456
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - cancelled
          example: pending
        created_at:
          type: string
          format: date-time
          example: '2024-01-20T14:30:00Z'
        effective_date:
          type: string
          format: date
          example: '2024-03-01'
        change_summary:
          type: string
          example: 'Salary: $75,000 → $80,000'
        change_impact:
          type: object
          properties:
            type:
              type: string
              enum:
              - increase
              - decrease
              - neutral
              example: increase
            amount:
              type: number
              example: 5000.0
            percentage:
              type: number
              nullable: true
              example: 6.7
            description:
              type: string
              example: Increase of $5,000 (6.7%)
        approver:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        days_pending:
          type: integer
          example: 5
        is_urgent:
          type: boolean
          example: false
        effective_soon:
          type: boolean
          example: true
    CompensationRequestDetail:
      type: object
      description: Detailed view of compensation request
      properties:
        id:
          type: integer
          example: 456
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - cancelled
          example: pending
        created_at:
          type: string
          format: date-time
          example: '2024-01-20T14:30:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-20T14:30:00Z'
        effective_date:
          type: string
          format: date
          example: '2024-03-01'
        justification:
          type: string
          example: Requesting salary increase based on performance review and market
            analysis
        current_compensation:
          type: object
          properties:
            type:
              type: string
              example: salary
            annual_salary:
              type: number
              nullable: true
              example: 75000.0
            hourly_rate:
              type: number
              nullable: true
              example:
            pay_frequency:
              type: string
              example: monthly
            currency:
              type: string
              example: USD
        requested_compensation:
          type: object
          properties:
            type:
              type: string
              example: salary
            annual_salary:
              type: number
              nullable: true
              example: 80000.0
            hourly_rate:
              type: number
              nullable: true
              example:
            pay_frequency:
              type: string
              example: monthly
            currency:
              type: string
              example: USD
        changes:
          type: object
          properties:
            has_salary_change:
              type: boolean
              example: true
            has_hourly_change:
              type: boolean
              example: false
            has_type_change:
              type: boolean
              example: false
            has_frequency_change:
              type: boolean
              example: false
            has_currency_change:
              type: boolean
              example: false
            summary:
              type: string
              example: 'Salary: $75,000 → $80,000'
            impact:
              type: object
              properties:
                type:
                  type: string
                  example: increase
                amount:
                  type: number
                  example: 5000.0
                percentage:
                  type: number
                  example: 6.7
                description:
                  type: string
                  example: Increase of $5,000 (6.7%)
        approval:
          type: object
          properties:
            approver:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                  example: 789
                name:
                  type: string
                  example: Jane Manager
            approved_at:
              type: string
              format: date-time
              nullable: true
              example:
            rejected_at:
              type: string
              format: date-time
              nullable: true
              example:
            manager_notes:
              type: string
              nullable: true
              example:
        status_info:
          type: object
          properties:
            days_pending:
              type: integer
              example: 5
            is_urgent:
              type: boolean
              example: false
            effective_soon:
              type: boolean
              example: true
            can_edit:
              type: boolean
              example: true
            can_cancel:
              type: boolean
              example: true
    CompensationRequestInput:
      type: object
      description: Input schema for creating/updating compensation requests
      required:
      - justification
      - requested_compensation_type
      properties:
        justification:
          type: string
          minLength: 10
          maxLength: 1000
          example: Requesting salary increase based on performance review and market
            analysis
        effective_date:
          type: string
          format: date
          example: '2024-03-01'
        requested_compensation_type:
          type: string
          enum:
          - salary
          - hourly
          - commission
          - contract
          example: salary
        requested_annual_salary:
          type: number
          minimum: 0
          nullable: true
          example: 80000.0
        requested_hourly_rate:
          type: number
          minimum: 0
          nullable: true
          example:
        requested_pay_frequency:
          type: string
          enum:
          - weekly
          - biweekly
          - monthly
          - quarterly
          - annually
          example: monthly
        requested_currency:
          type: string
          example: USD
    CompensationRequestStatistics:
      type: object
      description: Statistics about employee's compensation requests
      properties:
        total:
          type: integer
          example: 3
        pending:
          type: integer
          example: 1
        approved:
          type: integer
          example: 1
        rejected:
          type: integer
          example: 0
        cancelled:
          type: integer
          example: 1
    EPMSDashboard:
      type: object
      description: EPMS dashboard data with aggregated goals, reviews, feedback, and
        meetings
      properties:
        goals:
          type: object
          properties:
            active_count:
              type: integer
              example: 5
            completed_count:
              type: integer
              example: 12
            overdue_count:
              type: integer
              example: 2
            recent_updates:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSGoal"
        reviews:
          type: object
          properties:
            pending_count:
              type: integer
              example: 1
            in_progress_count:
              type: integer
              example: 2
            completed_count:
              type: integer
              example: 8
            recent_reviews:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSPerformanceReview"
        feedback:
          type: object
          properties:
            received_count:
              type: integer
              example: 15
            pending_acknowledgment:
              type: integer
              example: 3
            recent_feedback:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSContinuousFeedback"
        meetings:
          type: object
          properties:
            upcoming_count:
              type: integer
              example: 4
            recent_count:
              type: integer
              example: 6
            recent_meetings:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSMeeting"
        action_items:
          type: array
          items:
            type: object
            properties:
              type:
                type: string
                enum:
                - goal_update
                - review_submit
                - feedback_acknowledge
                - meeting_schedule
              title:
                type: string
              due_date:
                type: string
                format: date
                nullable: true
              priority:
                type: string
                enum:
                - low
                - medium
                - high
                - critical
    EPMSTeamDashboard:
      type: object
      description: Team dashboard data for managers
      properties:
        team_stats:
          type: object
          properties:
            team_size:
              type: integer
              example: 12
            active_goals:
              type: integer
              example: 45
            pending_reviews:
              type: integer
              example: 8
            pending_approvals:
              type: integer
              example: 5
        team_goals:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoal"
        pending_reviews:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSPerformanceReview"
        team_feedback:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSContinuousFeedback"
        upcoming_meetings:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSMeeting"
    EPMSGoal:
      type: object
      description: Employee goal with progress tracking. Progress updates are returned
        as a sibling array in GET /goals/{id}, not nested in the goal object.
      properties:
        id:
          type: integer
          example: 123
        title:
          type: string
          example: Increase sales by 20%
        description:
          type: string
          nullable: true
          example: Achieve 20% growth in Q1 sales
        goal_type:
          type: string
          enum:
          - performance
          - development
          - behavior
          - project
          - skill
          example: performance
        goal_type_label:
          type: string
          nullable: true
          description: Human-readable label for goal type
        goal_category:
          type: string
          nullable: true
          description: The focus area category key for this goal
          example: professional_development
        goal_category_label:
          type: string
          nullable: true
          description: Human-readable label for the goal category
          example: Professional Development
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          example: high
        priority_label:
          type: string
          nullable: true
          description: Human-readable label for priority
        status:
          type: string
          enum:
          - draft
          - in_review
          - active
          - on_hold
          - completed
          - cancelled
          - overdue
          example: active
        display_status:
          type: string
          nullable: true
          description: Human-readable display status
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_date:
          type: string
          format: date
          example: '2026-03-31'
        completed_date:
          type: string
          format: date
          nullable: true
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 45.5
        weight_percentage:
          type: number
          nullable: true
          example: 30.0
        workflow_stage:
          type: string
          enum:
          - draft
          - employee_review
          - manager_finalized
          - leadership_approved
          description: Current workflow stage
        workflow_stage_label:
          type: string
          nullable: true
        is_smart_goal:
          type: boolean
          example: true
        smart_score:
          type: number
          nullable: true
        on_track:
          type: boolean
          nullable: true
        days_until_due:
          type: integer
          nullable: true
        is_overdue:
          type: boolean
          example: false
        progress_update_allowed:
          type: boolean
          description: Whether progress updates can be submitted for this goal
        success_criteria:
          type: string
          nullable: true
          description: Present when include_details is true (e.g. show endpoint)
          example: Reach $500K in sales
        smart_criteria:
          type: object
          nullable: true
          description: Present when include_details is true
          properties:
            is_specific:
              type: boolean
            is_measurable:
              type: boolean
            is_achievable:
              type: boolean
            is_relevant:
              type: boolean
            is_time_bound:
              type: boolean
        progress_updates_count:
          type: integer
          description: Present when include_details is true
        latest_progress_update:
          "$ref": "#/components/schemas/EPMSProgressUpdate"
          nullable: true
          description: Present when include_details is true
        can_edit:
          type: boolean
          description: Present when include_details is true
        can_complete:
          type: boolean
          description: Present when include_details is true
        can_delete:
          type: boolean
          description: Present when include_details is true
        requires_manager_approval:
          type: boolean
          description: Present when include_details is true
        is_fully_approved:
          type: boolean
          description: Present when include_details is true
        in_review_stage:
          type: boolean
          description: Present when include_details is true
        can_cancel:
          type: boolean
          description: Present when include_details is true
        can_put_on_hold:
          type: boolean
          description: Present when include_details is true
        can_reactivate:
          type: boolean
          description: Present when include_details is true
        can_send_to_employee:
          type: boolean
          description: Present when include_details is true
        can_employee_review:
          type: boolean
          description: Present when include_details is true
        can_manager_finalize:
          type: boolean
          description: Present when include_details is true
        can_leadership_approve:
          type: boolean
          description: Present when include_details is true
        manager_approved_at:
          type: string
          format: date-time
          nullable: true
          description: Present when include_details is true
        leadership_approved_at:
          type: string
          format: date-time
          nullable: true
          description: Present when include_details is true
        employee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            email:
              type: string
              nullable: true
              example: john@example.com
            job_title:
              type: string
              nullable: true
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
    EPMSGoalInput:
      type: object
      description: Input schema for creating/updating goals
      required:
      - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Increase sales by 20%
        description:
          type: string
          maxLength: 2000
          example: Achieve 20% growth in Q1 sales
        goal_type:
          type: string
          enum:
          - performance
          - development
          - behavior
          - project
          - skill
          example: performance
        goal_category:
          type: string
          maxLength: 50
          description: 'Focus area category key. Required on create for non-department
            goals. Must be a valid category configured for the business. Use `GET
            /epms/goals/categories` to retrieve valid values. Default categories for
            IC: professional_development, lead_self, work_with_others, contribute_to_business.
            Default categories for leaders: professional_development, lead_self, lead_others,
            lead_the_business. Business configuration may differ.

            '
          example: professional_development
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          example: high
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_date:
          type: string
          format: date
          example: '2026-03-31'
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 0
        weight_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 30.0
        success_criteria:
          type: string
          maxLength: 500
          example: Reach $500K in sales
        employee_id:
          type: integer
          example: 456
        is_specific:
          type: boolean
          example: true
        is_measurable:
          type: boolean
          example: true
        is_achievable:
          type: boolean
          example: true
        is_relevant:
          type: boolean
          example: true
        is_time_bound:
          type: boolean
          example: true
    EPMSGoalCategoryOption:
      type: object
      description: A goal category option with value and human-readable label
      properties:
        value:
          type: string
          description: Category key to send on create/update
          example: professional_development
        label:
          type: string
          description: Human-readable label for display
          example: Professional Development
    EPMSGoalCategoriesResponse:
      type: object
      description: Response containing valid goal categories for a user's role
      properties:
        categories:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
    EPMSGoalTemplateOption:
      type: object
      description: A goal template available for prefilling the create form
      properties:
        id:
          type: integer
          example: 1
        title:
          type: string
          example: Improve Task Completion
        description:
          type: string
          nullable: true
          example: Focus on completing tasks on time and improving quality.
        goal_type:
          type: string
          example: performance
        priority:
          type: string
          example: medium
        success_criteria:
          type: string
          nullable: true
          example: Task completion rate above 90%
        resources_needed:
          type: string
          nullable: true
          example: Project management tool access
        measurement_method:
          type: string
          nullable: true
          example: Weekly task completion reports
        is_default:
          type: boolean
          example: true
        smart_criteria:
          "$ref": "#/components/schemas/EPMSSmartCriteria"
    EPMSGoalChecklistItem:
      type: object
      description: A SMART checklist item
      properties:
        key:
          type: string
          example: is_specific
        label:
          type: string
          example: Specific
    EPMSGoalChecklist:
      type: object
      description: SMART goal checklist configuration
      properties:
        enforced:
          type: boolean
          description: Whether the SMART checklist is enforced for goal creation
          example: false
        items:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalChecklistItem"
    EPMSGoalCreationConfigResponse:
      type: object
      description: All dynamic properties needed to render the goal create form
      properties:
        goal_templates:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalTemplateOption"
        goal_types:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        priorities:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        goal_categories:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        goal_checklist:
          "$ref": "#/components/schemas/EPMSGoalChecklist"
        requires_approval:
          type: boolean
          example: true
    EPMSSmartCriterionResult:
      type: object
      properties:
        score:
          type: integer
          minimum: 1
          maximum: 10
          example: 8
        feedback:
          type: string
          example: The goal is specific and clear
        suggestion:
          type: string
          example: Include specific teams responsible
    EPMSSmartAnalysisResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        overall_score:
          type: integer
          minimum: 0
          maximum: 100
          example: 75
        criteria:
          type: object
          properties:
            specific:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            measurable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            achievable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            relevant:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            time_bound:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
        improved_title:
          type: string
          nullable: true
        improved_description:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
        goal:
          "$ref": "#/components/schemas/EPMSGoal"
    EPMSSmartTextAnalysisResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        overall_score:
          type: integer
          minimum: 0
          maximum: 100
        criteria:
          type: object
          properties:
            specific:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            measurable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            achievable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            relevant:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            time_bound:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
        improved_title:
          type: string
          nullable: true
        improved_description:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
    EPMSGoalTemplate:
      type: object
      description: Goal template summary
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
        goal_type:
          type: string
        goal_type_label:
          type: string
        priority:
          type: string
        priority_label:
          type: string
        is_default:
          type: boolean
        template_type:
          type: string
          enum:
          - system
          - custom
        smart_score:
          type: integer
        is_smart_template:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    EPMSGoalTemplateDetail:
      allOf:
      - "$ref": "#/components/schemas/EPMSGoalTemplate"
      - type: object
        properties:
          success_criteria:
            type: string
            nullable: true
          resources_needed:
            type: string
            nullable: true
          measurement_method:
            type: string
            nullable: true
          smart_criteria:
            type: object
            properties:
              is_specific:
                type: boolean
              is_measurable:
                type: boolean
              is_achievable:
                type: boolean
              is_relevant:
                type: boolean
              is_time_bound:
                type: boolean
          missing_smart_criteria:
            type: array
            items:
              type: string
          usage_statistics:
            type: object
            properties:
              total_goals:
                type: integer
              active_goals:
                type: integer
              completed_goals:
                type: integer
              completion_rate:
                type: number
              last_used:
                type: string
                format: date-time
                nullable: true
    EPMSGoalTemplateListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalTemplate"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
    EPMSProgressUpdate:
      type: object
      description: Goal progress update record
      properties:
        id:
          type: integer
          example: 789
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 50.0
        update_notes:
          type: string
          nullable: true
          example: Halfway through the quarter, on track
        update_date:
          type: string
          format: date
          example: '2026-01-15'
        update_type:
          type: string
          enum:
          - regular
          - milestone
          - completion
          - revision
          - comment
          example: regular
        challenges_faced:
          type: string
          nullable: true
        support_needed:
          type: string
          nullable: true
        attachments:
          type: array
          items:
            type: string
          nullable: true
        updated_by:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T10:30:00Z'
    EPMSProgressUpdateInput:
      type: object
      description: Input schema for progress updates
      properties:
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          description: Required for all types except 'comment'
          example: 50.0
        update_notes:
          type: string
          maxLength: 1000
          example: Halfway through the quarter, on track
        update_type:
          type: string
          enum:
          - regular
          - milestone
          - completion
          - revision
          - comment
          default: regular
          example: regular
        challenges_faced:
          type: string
          nullable: true
          description: Obstacles or challenges encountered
          example: Dependency on external vendor delayed delivery
        support_needed:
          type: string
          nullable: true
          description: Support or resources needed
          example: Need additional budget approval for tooling
        attachments:
          type: array
          items:
            type: string
          nullable: true
          description: File references/attachment identifiers
    EPMSGoalStats:
      type: object
      description: Goal statistics for dashboard
      properties:
        total_goals:
          type: integer
          example: 20
        active_goals:
          type: integer
          example: 12
        completed_goals:
          type: integer
          example: 6
        overdue_goals:
          type: integer
          example: 2
        on_hold_goals:
          type: integer
          example: 0
        cancelled_goals:
          type: integer
          example: 0
        due_soon_count:
          type: integer
          description: Goals due within the next 7 days
          example: 3
        average_progress:
          type: number
          example: 45.5
        completion_rate:
          type: number
          description: Percentage of completed goals (0-100)
          example: 30.0
        by_status:
          type: object
          additionalProperties:
            type: integer
          description: Counts per status (draft, active, on_hold, completed, cancelled,
            in_review, overdue)
        by_priority:
          type: object
          additionalProperties:
            type: integer
          description: Counts per priority (low, medium, high, critical)
        by_type:
          type: object
          additionalProperties:
            type: integer
          description: Counts per goal type (performance, development, behavior, project,
            skill)
    EPMSPerformanceReview:
      type: object
      description: Performance review with assessment details
      properties:
        id:
          type: integer
          example: 234
        title:
          type: string
          example: Q1 2026 Performance Review
        review_type:
          type: string
          enum:
          - annual
          - quarterly
          - probation
          - project
          - performance_improvement
          example: quarterly
        status:
          type: string
          enum:
          - created
          - in_progress
          - submitted
          - approved
          - completed
          - cancelled
          example: in_progress
        display_status:
          type: string
          description: Computed status that accounts for overdue reviews
          example: in_progress
        status_color:
          type: string
          description: Bootstrap badge color variant for the display status
          enum:
          - info
          - primary
          - success
          - danger
          - secondary
          example: primary
        review_period_start:
          type: string
          format: date
          example: '2026-01-01'
        review_period_end:
          type: string
          format: date
          example: '2026-03-31'
        due_date:
          type: string
          format: date
          example: '2026-04-15'
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        manager:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        overall_rating:
          type: number
          nullable: true
          minimum: 0
          maximum: 5
          example: 4.5
        manager_comments:
          type: string
          nullable: true
          example: Great progress this quarter
        self_assessment:
          type: object
          nullable: true
          properties:
            overall_summary:
              type: string
            strengths:
              type: string
            areas_for_improvement:
              type: string
            self_rating:
              type: number
              minimum: 0
              maximum: 5
        linked_goals:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoal"
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
        is_overdue:
          type: boolean
          example: false
    EPMSPerformanceReviewInput:
      type: object
      description: Input schema for creating/updating performance reviews
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Q1 2026 Performance Review
        employee_id:
          type: integer
          example: 456
        review_type:
          type: string
          enum:
          - annual
          - quarterly
          - probation
          - project
          - performance_improvement
          example: quarterly
        review_period_start:
          type: string
          format: date
          example: '2026-01-01'
        review_period_end:
          type: string
          format: date
          example: '2026-03-31'
        due_date:
          type: string
          format: date
          example: '2026-04-15'
        performance_review_template_id:
          type: integer
          nullable: true
        manager_comments:
          type: string
          maxLength: 5000
          example: Great progress this quarter
        overall_rating:
          type: number
          minimum: 0
          maximum: 5
          example: 4.5
    EPMSSelfAssessmentInput:
      type: object
      description: Input schema for self-assessment submission
      properties:
        overall_summary:
          type: string
          maxLength: 2000
          example: I achieved all my goals this quarter
        strengths:
          type: string
          maxLength: 1000
          example: Strong communication and leadership
        areas_for_improvement:
          type: string
          maxLength: 1000
          example: Time management
        accomplishments:
          type: string
          maxLength: 2000
          example: Completed 3 major projects
        goals_reflection:
          type: string
          maxLength: 1000
          example: Met 90% of goals
        training_needs:
          type: string
          maxLength: 500
          example: Advanced project management
        career_aspirations:
          type: string
          maxLength: 1000
          example: Lead a larger team
        self_rating:
          type: number
          minimum: 0
          maximum: 5
          example: 4.0
        ratings:
          type: array
          items:
            type: object
            properties:
              category:
                type: string
                example: Communication
              rating:
                type: number
                minimum: 0
                maximum: 5
                example: 4.5
              comments:
                type: string
                example: Excellent
        goals:
          type: array
          items:
            type: object
            properties:
              goal_id:
                type: integer
                example: 123
              achievement_notes:
                type: string
                example: Exceeded expectations
              self_rating:
                type: number
                minimum: 0
                maximum: 5
                example: 5.0
    EPMSPerformanceReviewStats:
      type: object
      description: Performance review statistics
      properties:
        total_reviews:
          type: integer
          example: 15
        pending_reviews:
          type: integer
          example: 5
        completed_reviews:
          type: integer
          example: 8
        overdue_reviews:
          type: integer
          example: 2
        due_soon_count:
          type: integer
          example: 3
        completion_rate:
          type: number
          example: 53.3
        by_status:
          type: object
          description: Count of reviews by status key
          additionalProperties:
            type: integer
          example:
            created: 2
            in_progress: 5
            completed: 3
            approved: 2
            archived: 1
        by_type:
          type: object
          description: Count of reviews by review_type key
          additionalProperties:
            type: integer
          example:
            annual: 8
            quarterly: 5
            ad_hoc: 2
        average_rating:
          type: number
          nullable: true
          example: 4.2
    EPMSMyReviewSummary:
      type: object
      description: Personal review summary for the current user (matches "My Review
        Status" widget)
      properties:
        total_reviews:
          type: integer
          description: Total number of reviews where the user is the employee
          example: 5
        completed_reviews:
          type: integer
          description: Reviews with status completed or approved
          example: 3
        pending_reviews:
          type: integer
          description: Reviews with status created or in_progress
          example: 1
        average_rating:
          type: number
          description: Average overall_rating across completed/approved reviews (rounded
            to 1 decimal, defaults to 0)
          example: 4.2
        has_active_review:
          type: boolean
          description: Whether the user has an active (created or in_progress) review
          example: true
        current_review:
          type: object
          nullable: true
          description: The most recent active review summary
          properties:
            id:
              type: integer
            title:
              type: string
            status:
              type: string
            display_status:
              type: string
            status_color:
              type: string
              enum:
              - info
              - primary
              - success
              - danger
              - secondary
            start_date:
              type: string
              format: date
              nullable: true
            due_date:
              type: string
              format: date
              nullable: true
            days_until_due:
              type: integer
              nullable: true
            completion_percentage:
              type: integer
            requires_self_assessment:
              type: boolean
            self_assessment_completed:
              type: boolean
        pending_actions:
          type: array
          description: Status-based action items for the user
          items:
            type: object
            properties:
              type:
                type: string
                enum:
                - begin_review
                - complete_self_assessment
                - awaiting_approval
              text:
                type: string
              priority:
                type: string
                enum:
                - high
                - medium
                - low
    EPMSContinuousFeedback:
      type: object
      description: Continuous feedback record
      properties:
        id:
          type: integer
          example: 345
        subject:
          type: string
          example: Great work on the project
        content:
          type: string
          example: Your presentation was excellent and well-received by the team.
        feedback_type:
          type: string
          enum:
          - praise
          - constructive
          - goal_progress
          - general
          example: praise
        status:
          type: string
          enum:
          - draft
          - sent
          - acknowledged
          - archived
          example: sent
        visibility:
          type: string
          enum:
          - private
          - public
          - manager_only
          example: private
        is_anonymous:
          type: boolean
          example: false
        giver:
          type: object
          nullable: true
          description: 'Feedback giver info. Returns only `{ name: "Anonymous" }`
            when feedback is anonymous.'
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
            email:
              type: string
              format: email
              example: jane.manager@company.com
            job_title:
              type: string
              nullable: true
              example: Engineering Manager
            profile_photo_url:
              type: string
              format: uri
              nullable: true
              description: Full-size profile photo URL (200x200)
              example: https://example.com/photos/jane-200x200.jpg
            profile_photo_thumbnail_url:
              type: string
              format: uri
              nullable: true
              description: Thumbnail profile photo URL (40x40)
              example: https://example.com/photos/jane-40x40.jpg
        receiver:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            email:
              type: string
              format: email
              example: john.doe@company.com
            job_title:
              type: string
              nullable: true
              example: Senior Product Manager
            profile_photo_url:
              type: string
              format: uri
              nullable: true
              description: Full-size profile photo URL (200x200)
              example: https://example.com/photos/john-200x200.jpg
            profile_photo_thumbnail_url:
              type: string
              format: uri
              nullable: true
              description: Thumbnail profile photo URL (40x40)
              example: https://example.com/photos/john-40x40.jpg
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-01-20T10:00:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
        sent_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-01-15T15:00:00Z'
    EPMSContinuousFeedbackInput:
      type: object
      description: Input schema for creating/updating feedback
      required:
      - receiver_id
      - subject
      - content
      properties:
        receiver_id:
          type: integer
          example: 456
        subject:
          type: string
          minLength: 1
          maxLength: 255
          example: Great work on the project
        content:
          type: string
          minLength: 1
          maxLength: 2000
          example: Your presentation was excellent and well-received by the team.
        feedback_type:
          type: string
          enum:
          - praise
          - constructive
          - goal_progress
          - general
          default: praise
          example: praise
        visibility:
          type: string
          enum:
          - private
          - public
          - manager_only
          default: private
          example: private
        is_anonymous:
          type: boolean
          default: false
          example: false
    EPMSContinuousFeedbackStats:
      type: object
      description: Feedback statistics
      properties:
        given:
          type: object
          properties:
            total:
              type: integer
              example: 25
            by_type:
              type: object
              properties:
                praise:
                  type: integer
                  example: 15
                constructive:
                  type: integer
                  example: 5
                recognition:
                  type: integer
                  example: 3
                coaching:
                  type: integer
                  example: 2
        received:
          type: object
          properties:
            total:
              type: integer
              example: 18
            pending_acknowledgment:
              type: integer
              example: 3
            acknowledged:
              type: integer
              example: 15
    EPMSFeedbackPickerUser:
      type: object
      description: |
        Lightweight user object returned by the feedback user-picker endpoint.
        Used for selecting recipients (giving mode) or viewing feedback targets (receiving mode).
      properties:
        id:
          type: integer
          description: User's internal ID
          example: 456
        name:
          type: string
          description: User's full name
          example: Jane Smith
        email:
          type: string
          format: email
          nullable: true
          description: User's email address
          example: jane.smith@company.com
        job_title:
          type: string
          nullable: true
          description: User's job title
          example: Senior Engineer
        department:
          type: string
          nullable: true
          description: User's department name
          example: Engineering
        avatar_url:
          type: string
          format: uri
          nullable: true
          description: Full-size profile photo URL (200×200 px)
          example: https://example.com/photos/jane-200x200.jpg
        avatar_thumbnail_url:
          type: string
          format: uri
          nullable: true
          description: Thumbnail profile photo URL (40×40 px)
          example: https://example.com/photos/jane-40x40.jpg
      required:
      - id
      - name
    EPMSDevelopmentPlan:
      type: object
      description: Employee development plan
      properties:
        id:
          type: integer
          example: 456
        title:
          type: string
          example: Leadership Development Plan
        description:
          type: string
          nullable: true
          example: Focus on developing leadership skills
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - cancelled
          example: active
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_completion_date:
          type: string
          format: date
          nullable: true
          example: '2026-12-31'
        plan_type:
          type: string
          nullable: true
          example: leadership
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        manager:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        goals:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoal"
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
    EPMSDevelopmentPlanInput:
      type: object
      description: Input schema for creating/updating development plans
      required:
      - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Leadership Development Plan
        description:
          type: string
          maxLength: 2000
          example: Focus on developing leadership skills
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - cancelled
          example: active
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_completion_date:
          type: string
          format: date
          nullable: true
          example: '2026-12-31'
        employee_id:
          type: integer
          example: 456
        plan_type:
          type: string
          example: leadership
    EPMSDevelopmentGoalInput:
      type: object
      description: Input schema for development plan goals
      required:
      - title
      - target_date
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Complete Leadership Training
        description:
          type: string
          maxLength: 2000
          example: Finish the leadership certification program
        goal_type:
          type: string
          enum:
          - skill
          - experience
          - education
          - certification
          - project
          example: skill
        status:
          type: string
          enum:
          - not_started
          - in_progress
          - completed
          - cancelled
          example: not_started
        target_date:
          type: string
          format: date
          example: '2026-06-30'
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          default: 0
          example: 0
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          example: high
    EPMSMeeting:
      type: object
      description: One-on-one meeting or check-in
      properties:
        id:
          type: integer
          example: 567
        title:
          type: string
          example: Q1 Check-in
        meeting_type:
          type: string
          enum:
          - performance_review
          - goal_discussion
          - feedback_session
          - check_in
          - development_planning
          - career_discussion
          example: check_in
        status:
          type: string
          enum:
          - scheduled
          - confirmed
          - in_progress
          - completed
          - cancelled
          - rescheduled
          example: scheduled
        scheduled_at:
          type: string
          format: date-time
          example: '2026-02-15T10:00:00Z'
        duration_minutes:
          type: integer
          example: 30
        location:
          type: string
          nullable: true
          example: Conference Room A
        description:
          type: string
          nullable: true
          example: Quarterly performance check-in
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        manager:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        notes:
          type: string
          nullable: true
          example: Great discussion about goals and progress
        completed_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-02-15T10:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T10:00:00Z'
    EPMSMeetingInput:
      type: object
      description: Input schema for creating/updating meetings
      required:
      - title
      - meeting_type
      - employee_id
      - scheduled_at
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Q1 Check-in
        meeting_type:
          type: string
          enum:
          - performance_review
          - goal_discussion
          - feedback_session
          - check_in
          - development_planning
          - career_discussion
          example: check_in
        employee_id:
          type: integer
          example: 456
        scheduled_at:
          type: string
          format: date-time
          example: '2026-02-15T10:00:00Z'
        duration_minutes:
          type: integer
          default: 30
          example: 30
        location:
          type: string
          maxLength: 255
          example: Conference Room A
        description:
          type: string
          maxLength: 2000
          example: Quarterly performance check-in
    EPMSCompetencyFramework:
      type: object
      description: Competency framework with defined competencies
      properties:
        id:
          type: integer
          example: 678
        name:
          type: string
          example: Leadership Competencies
        description:
          type: string
          nullable: true
          example: Core leadership competencies for management roles
        framework_type:
          type: string
          enum:
          - core
          - leadership
          - technical
          - functional
          - role_specific
          example: leadership
        competencies:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Communication
              description:
                type: string
                nullable: true
                example: Ability to communicate effectively
              level:
                type: string
                enum:
                - beginner
                - intermediate
                - advanced
                - expert
                nullable: true
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
    EPMSCompetencyAssessment:
      type: object
      description: Competency assessment with ratings
      properties:
        id:
          type: integer
          example: 789
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        competency_framework:
          type: object
          properties:
            id:
              type: integer
              example: 678
            name:
              type: string
              example: Leadership Competencies
        assessment_type:
          type: string
          enum:
          - self_assessment
          - manager_assessment
          - peer_assessment
          - 360_assessment
          example: manager_assessment
        status:
          type: string
          enum:
          - draft
          - in_progress
          - completed
          example: completed
        assessed_at:
          type: string
          format: date
          example: '2026-01-15'
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-01-31'
        overall_score:
          type: number
          nullable: true
          minimum: 0
          maximum: 5
          example: 4.2
        overall_comments:
          type: string
          nullable: true
          example: Strong performance across all competencies
        competency_ratings:
          type: array
          items:
            type: object
            properties:
              competency_id:
                type: integer
                example: 1
              competency_name:
                type: string
                example: Communication
              rating_value:
                type: number
                minimum: 0
                maximum: 5
                example: 4.5
              comments:
                type: string
                nullable: true
                example: Excellent communication skills
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
    EPMSCompetencyAssessmentInput:
      type: object
      description: Input schema for creating/updating competency assessments
      required:
      - employee_id
      - competency_framework_id
      properties:
        employee_id:
          type: integer
          example: 456
        competency_framework_id:
          type: integer
          example: 678
        assessment_type:
          type: string
          enum:
          - self_assessment
          - manager_assessment
          - peer_assessment
          - 360_assessment
          example: manager_assessment
        assessed_at:
          type: string
          format: date
          example: '2026-01-15'
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-01-31'
        overall_score:
          type: number
          minimum: 0
          maximum: 5
          example: 4.2
        overall_comments:
          type: string
          maxLength: 2000
          example: Strong performance across all competencies
        status:
          type: string
          enum:
          - draft
          - in_progress
          - completed
          example: completed
        competency_ratings_attributes:
          type: array
          items:
            type: object
            required:
            - competency_id
            - rating_value
            properties:
              competency_id:
                type: integer
                example: 1
              rating_value:
                type: number
                minimum: 0
                maximum: 5
                example: 4.5
              comments:
                type: string
                maxLength: 1000
                example: Excellent communication skills
    FormSubmission:
      type: object
      description: Form submission data and metadata
      properties:
        id:
          type: integer
          description: Unique submission ID
          example: 789
        status:
          type: string
          enum:
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          - pending_completion
          - changes_requested
          description: Submission status
          example: submitted
        completion_percentage:
          type: number
          minimum: 0
          maximum: 100
          description: Form completion percentage
          example: 100
        submission_data:
          type: object
          description: Form field values
          additionalProperties: true
          example:
            incident_type: injury
            description: Employee slipped on wet floor
            severity: minor
        submitted_at:
          type: string
          format: date-time
          description: Submission timestamp
          nullable: true
          example: '2024-01-15T14:30:00Z'
        created_at:
          type: string
          format: date-time
          description: Creation timestamp
          example: '2024-01-15T14:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: Last update timestamp
          example: '2024-01-15T14:30:00Z'
        files:
          type: array
          description: Files uploaded with this submission
          items:
            type: object
            properties:
              id:
                type: integer
                example: 12
              field_name:
                type: string
                description: Name of the form field the file belongs to
                example: incident_photo
              file_type:
                type: string
                description: Detected file type (image, video, audio, pdf, document,
                  unknown)
                example: image
              filename:
                type: string
                description: Original filename
                example: photo.jpg
              url:
                type: string
                description: URL to download/view the file
                example: https://example.com/files/12
        device_info:
          type: object
          description: Device metadata captured at submission time
          additionalProperties: true
          nullable: true
        is_offline_submission:
          type: boolean
          description: Whether the submission was created from offline mode
          example: false
      required:
      - id
      - status
      - submission_data
      - completion_percentage
    ReviewedSubmission:
      allOf:
      - type: object
        description: Form submission data and metadata
        properties:
          id:
            type: integer
            description: Unique submission ID
            example: 789
          status:
            type: string
            enum:
            - draft
            - submitted
            - under_review
            - approved
            - rejected
            - pending_completion
            - changes_requested
            description: Submission status
            example: submitted
          completion_percentage:
            type: number
            minimum: 0
            maximum: 100
            description: Form completion percentage
            example: 100
          submission_data:
            type: object
            description: Form field values
            additionalProperties: true
            example:
              incident_type: injury
              description: Employee slipped on wet floor
              severity: minor
          submitted_at:
            type: string
            format: date-time
            description: Submission timestamp
            nullable: true
            example: '2024-01-15T14:30:00Z'
          created_at:
            type: string
            format: date-time
            description: Creation timestamp
            example: '2024-01-15T14:00:00Z'
          updated_at:
            type: string
            format: date-time
            description: Last update timestamp
            example: '2024-01-15T14:30:00Z'
          files:
            type: array
            description: Files uploaded with this submission
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 12
                field_name:
                  type: string
                  description: Name of the form field the file belongs to
                  example: incident_photo
                file_type:
                  type: string
                  description: Detected file type (image, video, audio, pdf, document,
                    unknown)
                  example: image
                filename:
                  type: string
                  description: Original filename
                  example: photo.jpg
                url:
                  type: string
                  description: URL to download/view the file
                  example: https://example.com/files/12
          device_info:
            type: object
            description: Device metadata captured at submission time
            additionalProperties: true
            nullable: true
          is_offline_submission:
            type: boolean
            description: Whether the submission was created from offline mode
            example: false
        required:
        - id
        - status
        - submission_data
        - completion_percentage
      - type: object
        properties:
          status_label:
            type: string
            description: Human-readable status label
            example: Approved
          reviewed_at:
            type: string
            format: date-time
            nullable: true
            description: When the review decision was taken
            example: '2026-06-24T14:30:00Z'
          review_round:
            type: integer
            description: |
              Which correction cycle the submission is on. 0 until the first
              field-level return; incremented by every
              `POST /form_submissions/{id}/return_fields`. Compare against a
              field's `field_review.round` to tell a current return from a
              historical one.
            example: 2
          review_notes:
            type: string
            nullable: true
            description: Reviewer notes (the rejection reason for reject)
            example: Approved — all required documents attached.
          reviewed_by:
            type: object
            nullable: true
            description: The reviewer who took the decision
            properties:
              id:
                type: integer
                example: 7
              name:
                type: string
                example: Anup Patel
    SubmissionTimelineEvent:
      type: object
      description: A single event on the submission status timeline
      properties:
        type:
          type: string
          enum:
          - submitted
          - approved
          - changes_requested
          - rejected
          description: Machine-readable event kind
        title:
          type: string
          description: Human-readable event title
          example: Rejected
        state:
          type: string
          enum:
          - done
          - warning
          - bad
          description: Visual marker state — done (success/neutral), warning (returned
            for correction), bad (rejected)
        at:
          type: string
          format: date-time
          description: When the event occurred
        by:
          type: object
          nullable: true
          description: The reviewer who performed the review event; null for the submitted
            event
          properties:
            id:
              type: integer
            name:
              type: string
              example: Reva Yu
        note:
          type: string
          nullable: true
          description: Reviewer's note for the outcome (e.g. rejection reason); null
            when none
          example: Please retake photo 2.
      required:
      - type
      - title
      - state
      - at
    LeaveRequest:
      type: object
      description: Basic leave request information
      properties:
        id:
          type: integer
          description: Unique leave request ID
          example: 123
        user_id:
          type: integer
          description: ID of the user who created the request
          example: 456
        leave_type:
          "$ref": "#/components/schemas/LeaveTypeBasic"
        start_date:
          type: string
          format: date
          description: Start date of leave
          example: '2024-03-15'
        end_date:
          type: string
          format: date
          description: End date of leave
          example: '2024-03-17'
        hours_calculated:
          type: number
          description: Total hours for this leave request (primary field)
          example: 24
        business_days:
          type: number
          description: 'DEPRECATED: Use hours_calculated instead. Returns same value
            as hours_calculated for backward compatibility.'
          example: 24
        status:
          type: string
          enum:
          - pending
          - approved
          - denied
          - cancelled
          - special_approval
          description: Current status of the leave request
          example: pending
        notes:
          type: string
          nullable: true
          description: Optional notes for the leave request
          example: Family vacation
        created_at:
          type: string
          format: date-time
          description: When the request was created
          example: '2024-02-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: When the request was last updated
          example: '2024-02-15T10:00:00Z'
      required:
      - id
      - user_id
      - leave_type
      - start_date
      - end_date
      - hours_calculated
      - status
      - created_at
      - updated_at
    LeaveRequestDetailed:
      allOf:
      - "$ref": "#/components/schemas/LeaveRequest"
      - type: object
        properties:
          approval_details:
            type: object
            description: Approval workflow information
            properties:
              requires_approval:
                type: boolean
                description: Whether this request requires approval
                example: true
              approved_by:
                type: object
                nullable: true
                description: User who approved the request
                properties:
                  id:
                    type: integer
                    example: 789
                  name:
                    type: string
                    example: Jane Manager
              approved_at:
                type: string
                format: date-time
                nullable: true
                description: When the request was approved
                example: '2024-02-16T14:30:00Z'
              denial_reason:
                type: string
                nullable: true
                description: Reason for denial if applicable
                example: Insufficient coverage during requested period
          conflicts:
            type: object
            description: Shift conflict information
            properties:
              has_conflicts:
                type: boolean
                description: Whether this request conflicts with assigned shifts
                example: false
              conflicting_shifts:
                type: array
                description: List of conflicting shifts
                items:
                  "$ref": "#/components/schemas/ShiftConflict"
          blackout_warnings:
            type: array
            description: Blackout period warnings
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                name:
                  type: string
                  example: Winter Holiday Season
                start_date:
                  type: string
                  format: date
                  example: '2024-12-15'
                end_date:
                  type: string
                  format: date
                  example: '2024-12-31'
                blocks_requests:
                  type: boolean
                  example: true
                message:
                  type: string
                  example: Leave requests are blocked during Winter Holiday Season
          coverage_impact:
            type: object
            nullable: true
            description: Coverage impact analysis
            properties:
              affects_coverage:
                type: boolean
                example: false
              coverage_percentage:
                type: number
                example: 85.5
              available_spots:
                type: integer
                example: 3
              message:
                type: string
                example: This request will not affect coverage limits
    LeaveBalance:
      type: object
      description: Leave balance information for a specific leave type. All numeric
        values are in HOURS.
      properties:
        leave_type_id:
          type: integer
          description: ID of the leave type
          example: 1
        leave_type:
          "$ref": "#/components/schemas/LeaveTypeBasic"
        year:
          type: integer
          description: Year for this balance
          example: 2024
        unit:
          type: string
          description: Unit of measurement for balance values
          example: hours
          enum:
          - hours
        accrued:
          type: number
          description: Total hours accrued for the year
          example: 120.0
        used:
          type: number
          description: Hours already used
          example: 40.0
        balance:
          type: number
          description: Available hours remaining
          example: 80.0
        pending:
          type: number
          description: Hours in pending requests
          example: 24.0
        percentages:
          type: object
          description: Usage percentages for progress indicators
          properties:
            used:
              type: number
              description: Percentage of accrued hours used
              example: 33.3
            pending:
              type: number
              description: Percentage of accrued hours pending
              example: 20.0
            available:
              type: number
              description: Percentage of accrued hours available
              example: 46.7
      required:
      - leave_type_id
      - leave_type
      - year
      - accrued
      - used
      - balance
      - pending
      - percentages
    LeaveBalanceDetailed:
      allOf:
      - "$ref": "#/components/schemas/LeaveBalance"
      - type: object
        properties:
          recent_usage:
            type: array
            description: Leave consumed against this balance in the last 6 months,
              newest first. Includes approved leave requests, usage an administrator
              recorded by hand, and hours paid out from banked time — all three move
              the balance's `used` figure. Pending requests are NOT included (they
              are not usage yet); read the balance's `pending` hours for those.
            items:
              type: object
              properties:
                id:
                  type: integer
                  description: Identifier within the table named by `record_type`.
                  example: 123
                record_type:
                  type: string
                  description: Which record `id` refers to. `leave_request` for an
                    approved request; `leave_ledger_entry` for administrator- recorded
                    usage or a banked-time payout.
                  enum:
                  - leave_request
                  - leave_ledger_entry
                  example: leave_request
                source:
                  type: string
                  enum:
                  - leave_request
                  - manual
                  - payout
                  example: leave_request
                start_date:
                  type: string
                  format: date
                  example: '2024-01-15'
                end_date:
                  type: string
                  format: date
                  example: '2024-01-17'
                hours:
                  type: number
                  description: Hours actually charged to the balance (the paid portion).
                    Use this figure to reconcile against `used`.
                  example: 16
                unpaid_hours:
                  type: number
                  description: Portion taken as unpaid leave, which never touches
                    the balance.
                  example: 8
                hours_calculated:
                  type: number
                  description: Total hours on the request, including any unpaid portion.
                    Equals `hours` unless the request has an unpaid split.
                  example: 24
                business_days:
                  type: number
                  description: 'DEPRECATED: Use hours_calculated'
                  example: 24
                status:
                  type: string
                  nullable: true
                  description: Always `approved` for a leave request; null for ledger
                    entries.
                  example: approved
                upcoming:
                  type: boolean
                  description: Approved but the time off has not happened yet.
                  example: false
                counts_toward_balance:
                  type: boolean
                  description: False for a historical record imported alongside the
                    balance figures its hours are already inside. Such rows are listed
                    but excluded from `used`.
                  example: true
                notes:
                  type: string
                  nullable: true
                  example: Vacation
                edited:
                  type: boolean
                  description: The leave request has been modified since it was submitted.
                  example: false
                edit_count:
                  type: integer
                  example: 0
                last_edited_at:
                  type: string
                  format: date-time
                  nullable: true
                  example: '2024-02-01T09:30:00Z'
          accrual_policy:
            type: object
            nullable: true
            description: Accrual policy information
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Standard Vacation Policy
              accrual_rate:
                type: number
                description: Hours accrued per period
                example: 3.08
              accrual_unit:
                type: string
                description: Unit for all accrual values
                example: hours
                enum:
                - hours
              accrual_frequency:
                type: string
                example: bi-weekly
              max_balance:
                type: number
                description: Maximum balance cap in hours
                nullable: true
                example: 120.0
              max_accrual:
                type: number
                description: Deprecated, use max_balance
                nullable: true
                example: 120.0
              carryover_allowed:
                type: boolean
                example: true
              carryover_max:
                type: number
                description: Maximum hours to carry over
                nullable: true
                example: 40.0
              waiting_period_days:
                type: integer
                description: Calendar days before accrual starts
                example: 90
              proration_enabled:
                type: boolean
                example: true
          projected_balance:
            type: number
            description: Projected balance (hours) at end of year
            example: 100.0
    LeaveBalanceSummary:
      type: object
      description: Comprehensive leave balance summary
      properties:
        totals:
          type: object
          description: Totals across all leave types
          properties:
            accrued:
              type: number
              example: 25.0
            used:
              type: number
              example: 8.0
            pending:
              type: number
              example: 3.0
            available:
              type: number
              example: 14.0
            usage_percentage:
              type: number
              example: 32.0
        by_leave_type:
          type: array
          description: Balances by leave type
          items:
            "$ref": "#/components/schemas/LeaveBalance"
        upcoming_leave:
          type: array
          description: Upcoming approved leave
          items:
            type: object
            properties:
              id:
                type: integer
                example: 123
              leave_type:
                type: object
                properties:
                  id:
                    type: integer
                    example: 1
                  name:
                    type: string
                    example: Vacation
                  color:
                    type: string
                    example: "#4CAF50"
              start_date:
                type: string
                format: date
                example: '2024-04-15'
              end_date:
                type: string
                format: date
                example: '2024-04-17'
              hours:
                type: number
                description: Hours that will be charged to the balance (the paid portion).
                example: 16
              unpaid_hours:
                type: number
                description: Portion to be taken as unpaid leave, which never touches
                  the balance.
                example: 8
              hours_calculated:
                type: number
                description: Total hours on the request, including any unpaid portion.
                  Equals `hours` unless the request has an unpaid split.
                example: 24
              business_days:
                type: number
                description: 'DEPRECATED: Use hours_calculated'
                example: 24
              notes:
                type: string
                nullable: true
                example: Spring vacation
        year:
          type: integer
          description: Year for this summary
          example: 2024
      required:
      - totals
      - by_leave_type
      - upcoming_leave
      - year
    LeaveType:
      type: object
      description: Leave type information
      properties:
        id:
          type: integer
          description: Unique leave type ID
          example: 1
        name:
          type: string
          description: Name of the leave type
          example: Vacation
        color:
          type: string
          description: Color code for UI display
          example: "#4CAF50"
        icon:
          type: string
          description: Icon class for UI display
          example: fas fa-calendar-star
        active:
          type: boolean
          description: Whether this leave type is active
          example: true
        default:
          type: boolean
          description: Whether this is the default leave type
          example: false
        current_balance:
          type: object
          nullable: true
          description: Current balance for this leave type (if requested)
          properties:
            accrued:
              type: number
              example: 15.0
            used:
              type: number
              example: 5.0
            balance:
              type: number
              example: 10.0
            year:
              type: integer
              example: 2024
      required:
      - id
      - name
      - color
      - icon
      - active
      - default
    UserAvailability:
      type: object
      description: User availability block information
      properties:
        id:
          type: integer
          description: Unique availability ID
          example: 123
        specific_date:
          type: string
          format: date
          description: Date for this availability block
          example: '2025-10-15'
        start_time:
          type: string
          format: time
          description: Start time (displayed in user's timezone)
          example: '09:00'
        end_time:
          type: string
          format: time
          description: End time (displayed in user's timezone)
          example: '17:00'
        notes:
          type: string
          nullable: true
          description: Optional notes about this availability
          example: Available for morning shift
        store_in_utc:
          type: boolean
          description: Whether times are stored in UTC
          example: true
        created_at:
          type: string
          format: date-time
          description: When this availability was created
          example: '2025-10-01T10:30:00Z'
        updated_at:
          type: string
          format: date-time
          description: When this availability was last updated
          example: '2025-10-01T10:30:00Z'
      required:
      - id
      - specific_date
      - start_time
      - end_time
    WeeklyAvailabilityConfirmation:
      type: object
      description: Weekly availability confirmation information
      properties:
        id:
          type: integer
          description: Unique confirmation ID
          example: 456
        start_date:
          type: string
          format: date
          description: Week start date
          example: '2025-10-13'
        end_date:
          type: string
          format: date
          description: Week end date
          example: '2025-10-19'
        confirmed_at:
          type: string
          format: date-time
          description: When the availability was confirmed
          example: '2025-10-12T15:30:00Z'
        notes:
          type: string
          nullable: true
          description: Optional notes about the confirmation
          example: Confirmed for this week
        auto_generated:
          type: boolean
          description: Whether this was auto-generated by the system
          example: false
        created_at:
          type: string
          format: date-time
          description: When this confirmation was created
          example: '2025-10-12T15:30:00Z'
        updated_at:
          type: string
          format: date-time
          description: When this confirmation was last updated
          example: '2025-10-12T15:30:00Z'
      required:
      - id
      - start_date
      - end_date
      - confirmed_at
      - auto_generated
    LeaveTypeBasic:
      type: object
      description: Basic leave type information for references
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Vacation
        color:
          type: string
          example: "#4CAF50"
        icon:
          type: string
          example: fas fa-calendar-star
      required:
      - id
      - name
      - color
      - icon
    LeaveTypeDetailed:
      allOf:
      - "$ref": "#/components/schemas/LeaveType"
      - type: object
        properties:
          description:
            type: string
            nullable: true
            description: Description of the leave type
            example: Annual vacation leave for rest and recreation
          advance_notice_days:
            type: integer
            description: Required advance notice in days
            example: 7
          documentation_required:
            type: boolean
            description: Whether documentation is required
            example: false
          documentation_threshold_days:
            type: integer
            description: Threshold for requiring documentation
            example: 3
          requires_approval:
            type: boolean
            description: Whether requests require approval
            example: true
          deduct_from_balance:
            type: boolean
            description: Whether to deduct from balance
            example: true
          allow_negative_balance:
            type: boolean
            description: Whether negative balances are allowed
            example: false
          accrual_enabled:
            type: boolean
            description: Whether accrual is enabled
            example: true
          accrual_policy:
            type: object
            nullable: true
            description: Associated accrual policy
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Standard Vacation Policy
              accrual_rate:
                type: number
                example: 1.25
              accrual_frequency:
                type: string
                example: monthly
              accrual_frequency_display:
                type: string
                example: Monthly
              max_accrual:
                type: number
                nullable: true
                example: 30.0
              carryover_allowed:
                type: boolean
                example: true
              carryover_max:
                type: number
                nullable: true
                example: 5.0
              waiting_period_days:
                type: integer
                example: 90
              proration_enabled:
                type: boolean
                example: true
          business_rules:
            type: object
            description: Business rules and policies
            properties:
              advance_notice_required:
                type: boolean
                example: true
              documentation_rules:
                type: object
                properties:
                  required:
                    type: boolean
                    example: false
                  threshold_days:
                    type: integer
                    example: 3
              approval_workflow:
                type: object
                properties:
                  requires_approval:
                    type: boolean
                    example: true
                  auto_approve_threshold:
                    type: number
                    nullable: true
                    example:
              balance_rules:
                type: object
                properties:
                  deduct_from_balance:
                    type: boolean
                    example: true
                  allow_negative:
                    type: boolean
                    example: false
    ShiftConflict:
      type: object
      description: Information about a conflicting shift
      properties:
        id:
          type: integer
          description: Shift ID
          example: 789
        name:
          type: string
          description: Shift name
          example: Morning Shift
        start_time:
          type: string
          format: date-time
          description: Shift start time
          example: '2024-03-15T08:00:00Z'
        end_time:
          type: string
          format: date-time
          description: Shift end time
          example: '2024-03-15T16:00:00Z'
        location:
          type: object
          nullable: true
          description: Shift location
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Main Office
      required:
      - id
      - name
      - start_time
      - end_time
    PaginationMeta:
      type: object
      description: Pagination metadata
      properties:
        total_count:
          type: integer
          description: Total number of items
          example: 150
        total_pages:
          type: integer
          description: Total number of pages
          example: 6
        current_page:
          type: integer
          description: Current page number
          example: 1
        per_page:
          type: integer
          description: Items per page
          example: 25
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
    FormSummaryItem:
      type: object
      description: A published form as returned by GET /api/v1/forms
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: Daily Safety Checklist
        description:
          type: string
          nullable: true
          example: Complete before each shift.
        category:
          type: string
          example: safety
        priority:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          example: high
        field_count:
          type: integer
          example: 12
        required_field_count:
          type: integer
          example: 8
        page_count:
          type: integer
          description: Number of pages in the form (page_break fields + 1)
          example: 3
        estimated_time_minutes:
          type: integer
          example: 9
        has_file_uploads:
          type: boolean
          example: false
        sharing_enabled:
          type: boolean
          example: true
        requires_approval:
          type: boolean
          example: false
        accepting_submissions:
          type: boolean
          description: 'Whether the form is currently open for new responses. `false`
            when it passed its scheduled close time, reached its response cap, or
            is not published — exactly the states in which `GET /api/v1/forms/{id}`
            and `POST /api/v1/forms/{id}/submit` answer 403 `form_not_available` for
            a user who is not an admin, forms admin, or the form''s creator. Badge
            or disable the row instead of opening it.

            '
          example: true
        closed_reason:
          type: string
          nullable: true
          description: 'Short label naming WHY the form is not accepting responses,
            or `null` when `accepting_submissions` is true. Same vocabulary the web
            Published Forms list and the mobile cards badge with.

            '
          enum:
          - Closed
          - Response limit reached
          - Archived
          - Not published
          example:
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T09:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-04-10T14:22:00Z'
      required:
      - id
      - name
      - category
      - priority
      - field_count
      - required_field_count
      - page_count
      - estimated_time_minutes
      - has_file_uploads
      - sharing_enabled
      - requires_approval
      - accepting_submissions
      - closed_reason
      - created_at
      - updated_at
    FormCategoryItem:
      type: object
      description: A form category as returned by GET /api/v1/forms/categories
      properties:
        value:
          type: string
          description: Category slug — pass verbatim to GET /api/v1/forms?category=<value>
          example: safety
        label:
          type: string
          description: Human-readable category name
          example: Safety
        form_count:
          type: integer
          description: Number of published forms in this category for the caller's
            business
          example: 2
      required:
      - value
      - label
      - form_count
    ApprovalItem:
      type: object
      description: A submission in the reviewer's Approvals queue (GET /api/v1/forms/approvals)
      properties:
        id:
          type: integer
          example: 2231
        reference:
          type: string
          description: Display reference ("#<id>")
          example: "#2231"
        form_id:
          type: integer
          example: 7
        form_name:
          type: string
          example: Site Safety Audit
        form_description:
          type: string
          nullable: true
          example: Quarterly site-level safety walkthrough
        form_category:
          type: string
          example: safety
        priority:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          description: Form priority — drives the urgency chip
          example: high
        submitter:
          type: string
          description: Submitter's display name (or "Anonymous User")
          example: J. Rivera
        submitter_email:
          type: string
          nullable: true
          example: jrivera@example.com
        submitter_photo_url:
          type: string
          nullable: true
          description: Thumbnail avatar URL for the submitter; null for anonymous
            submissions
          example: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/representations/.../avatar.jpg
        status:
          type: string
          enum:
          - submitted
          - under_review
          example: under_review
        status_label:
          type: string
          example: Under review
        requires_approval:
          type: boolean
          example: true
        overdue:
          type: boolean
          description: |
            True when this row has been awaiting review longer than the server-side
            review SLA (currently 7 days on the `waiting_since` clock). Agrees
            row-for-row with the `?overdue=true` filter — badge from this rather
            than recomputing the threshold on the client, which would diverge the
            moment the server policy changed.
          example: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
        waiting_since:
          type: string
          format: date-time
          description: When the item entered the queue (submitted_at, falling back
            to created_at) — use for the "Waiting Nd" label
        updated_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
      required:
      - id
      - reference
      - form_id
      - form_name
      - form_category
      - priority
      - submitter
      - status
      - status_label
      - requires_approval
      - overdue
      - waiting_since
      - updated_at
      - created_at
    MySubmissionItem:
      type: object
      description: A single form submission as returned by GET /api/v1/forms/my_submissions
      properties:
        id:
          type: integer
          example: 1042
        form_id:
          type: integer
          example: 7
        form_name:
          type: string
          description: Used as the card title in the mobile list
          example: Equipment Transfer
        form_description:
          type: string
          nullable: true
          description: Used as the card subtitle
          example: Transfer equipment custody between sites
        form_category:
          type: string
          example: operations
        status:
          type: string
          enum:
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          - pending_completion
          - changes_requested
          example: draft
        status_label:
          type: string
          description: Human-readable label for the status badge
          example: Saved draft
        completion_percentage:
          type: integer
          minimum: 0
          maximum: 100
          description: Used for the Progress meta line
          example: 85
        submitted_at:
          type: string
          format: date-time
          nullable: true
          description: null for drafts
          example:
        reviewed_at:
          type: string
          format: date-time
          nullable: true
          description: When a reviewer approved or rejected; null if not yet reviewed
          example:
        review_notes:
          type: string
          nullable: true
          description: Reviewer's notes on approval or rejection
          example:
        updated_at:
          type: string
          format: date-time
          description: Used for the "Last updated" meta line
          example: '2026-06-11T16:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-06-10T08:00:00Z'
        is_offline_submission:
          type: boolean
          example: false
      required:
      - id
      - form_id
      - form_name
      - form_category
      - status
      - status_label
      - completion_percentage
      - updated_at
      - created_at
      - is_offline_submission
    NewsFeedSummary:
      type: object
      description: List-card serialization of a feed post.
      properties:
        id:
          type: integer
        content_type:
          type: string
          enum:
          - update
          - question
          - poll
        content_category:
          type: string
          enum:
          - operational
          - social
        source:
          type: object
          nullable: true
          description: |
            Provenance for app-contributed posts (`NewsFeed::FeedPublisher.contribute`).
            `null` for ordinary human composer posts.

            **This — not `content_type` — is the feed-type discriminator.** A
            Broadcast fans out to its audience and then contributes a feed row
            with a hardcoded `content_type` of `update`, so a broadcast-originated
            post is indistinguishable from a normal post by `content_type` alone.
            Detect one with `source && source.type == "Broadcast"`.

            Backed by columns on the feed row (`source_type` / `source_id` /
            `source_event`), so it is N+1-safe on the list path and identical on
            the list and detail endpoints.
          properties:
            type:
              type: string
              description: |
                Source model name. Known contributors:
                  * `Broadcast`                — a published Broadcast
                  * `CommsHub::Issue`          — a sent newsletter issue
                  * `Livestreaming::LiveEvent` — a livestream recording
                  * `Contests::Contest`        — contest winners announced

                Treat the set as open — plugins contribute under their own
                model names. Match on the exact string; do not parse it.
              example: Broadcast
            id:
              type: integer
              description: Primary key of the source record, in that model's own table.
              example: 45
            event:
              type: string
              description: |
                The lifecycle moment that produced this post. Part of the
                contribution de-dup key, so one source can contribute at most
                one feed per event. Known values: `broadcast_published`,
                `issue_published`, `recording_available`, `winners_announced`.
              example: broadcast_published
            critical:
              type: boolean
              description: |
                Whether the originating record is marked critical (`Broadcast`
                only today) — the red **CRITICAL** badge. Derived from
                provenance rather than copied onto the row, so downgrading the
                broadcast clears the badge and entries published before this
                shipped light up too.
              example: true
            channels:
              type: array
              items:
                type: string
                enum:
                - in_app
                - email
                - sms
                - voice
                - push
              description: |
                **Broadcast-sourced entries only.** The channels the broadcast
                actually fanned out on — the card's "Delivered in-app · Push ·
                SMS · Voice" row. It varies per broadcast (the author's channel
                toggles), so it cannot be inferred; the feed row carries no
                channel of its own. `in_app` always delivers; a channel appears
                unless the author switched it off.

                Absent when the post is not broadcast-sourced, or the broadcast
                no longer exists.
              example:
              - in_app
              - email
              - sms
              - push
            requires_acknowledgement:
              type: boolean
              description: |
                **Broadcast-sourced entries only.** Whether the BROADCAST asks
                for an acknowledgement.

                The post's own top-level `requires_acknowledgement` is
                deliberately `false` on these entries: the broadcast contributes
                at priority `operational` and never as must-read, so
                acknowledgement stays on ONE ledger rather than being minted a
                second time on the post. A client rendering the **Acknowledge**
                button therefore reads THIS flag and posts to
                `POST /api/v1/broadcasts/{source.id}/acknowledge` — not the
                feed's acknowledge endpoint, which records against the wrong
                ledger.
              example: true
            acknowledged:
              type: boolean
              description: |
                **Broadcast-sourced entries only.** Whether the CALLING user has
                already acknowledged that broadcast.
              example: false
          required:
          - type
          - id
          - event
          example:
            type: Broadcast
            id: 617
            event: broadcast_published
            critical: true
            channels:
            - in_app
            - email
            - sms
            - push
            requires_acknowledgement: true
            acknowledged: false
        priority:
          type: string
          enum:
          - must_read
          - operational
          - social
        is_announcement:
          type: boolean
          description: |
            Whether this post is the composer's **Announcement** kind.

            The Communications composer is one authoring surface with an
            intensity dial, and `priority` is how the chosen kind is stored:
            `social` → post, `operational` → **announcement**, `must_read` →
            must-read (a Broadcast is its own record, surfaced here via
            `source.type == "Broadcast"`). This flag is therefore exactly
            `priority == "operational"`, returned as a boolean alongside
            `must_read` so a client can render the Announcement badge without
            hardcoding that priority→kind table. Reading `priority` directly
            still works and is unchanged.

            Present on the list and detail endpoints alike, for every
            `content_type` (update / question / poll).

            Note: a broadcast-contributed post is stored at priority
            `operational` and so reports `true` here — check `source` when you
            need to tell a broadcast apart from a composed announcement.
          example: true
        must_read:
          type: boolean
        pinned:
          type: boolean
        pinned_at:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum:
          - draft
          - scheduled
          - published
          - expired
          - archived
        title:
          type: string
          nullable: true
        body:
          type: string
          nullable: true
        published_at:
          type: string
          format: date-time
          nullable: true
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
        editable:
          type: boolean
          description: |
            Whether **the calling user** may currently edit this post's content
            and attachments — author, and either the post is draft/scheduled or
            it was published within the last 15 minutes with the admin
            `content_editing` setting on. Lets clients show or hide the edit and
            add/remove-attachment controls instead of guessing; the server
            re-enforces it on every write regardless.
        is_edited:
          type: boolean
          description: |
            True once the author edited this post after it was published —
            a convenience boolean for `edited_at != null` so clients can render
            an "edited" marker without a null check. Applies to any
            `content_type` (update / question / poll).
        edited_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When the post was last edited after publishing. `null` until the
            first post-publish edit; drafts and scheduled posts never set it
            (the marker is published-only).
        audience_type:
          type: string
          enum:
          - everyone
          - segments
        audience_segment_ids:
          type: array
          items:
            type: string
        audience_segments:
          type: array
          description: |
            Parallel to `audience_segment_ids` — each segment id resolved to
            its human-readable name so clients can render segment chips
            without a second roundtrip. Numeric ids resolve via
            `NotificationRecipientGroup`; the `direct_reports` pseudo-segment
            resolves to "Author's direct reports". `name` is `null` when the
            underlying group has been deleted.
          items:
            type: object
            properties:
              id:
                type: string
              name:
                type: string
                nullable: true
        audience_size:
          type: integer
          nullable: true
          minimum: 0
          description: |
            Total addressable audience captured at publish time
            (PostAudience.snapshot_for!). Pair with `read_count` /
            `acknowledged_count` for "N of M" badges. `null` for feeds
            that predate the audience snapshot column.
        author_id:
          type: integer
          description: Convenience alias for `author.id` (kept for backwards-compat).
        author:
          "$ref": "#/components/schemas/NewsFeedUserRef"
        requires_acknowledgement:
          type: boolean
          description: |
            Whether THIS POST asks the reader to acknowledge it (must-read).

            Note for broadcast-sourced entries (`source.type == "Broadcast"`):
            this is `false` by design even when the broadcast requires an
            acknowledgement — the post is contributed at priority
            `operational`, so the acknowledgement stays on the broadcast's
            ledger rather than being minted twice. Read
            `source.requires_acknowledgement` / `source.acknowledged` for those
            cards, and acknowledge via
            `POST /api/v1/broadcasts/{source.id}/acknowledge`.
        policy:
          type: object
          nullable: true
          description: |
            The HR policy a Must-Read asks the reader to accept, plus THIS
            caller's acceptance of it. `null` when the post names no policy —
            which is most posts. Set at compose time via `feed[policy_id]` on
            `POST /feeds`; the selectable policies come from
            `GET /news-feed/policies`, and one policy's detail from
            `GET /news-feed/policies/{id}`.

            Acceptance is tracked separately from acknowledging the post: a
            reader can have acknowledged the must-read without having accepted
            the policy, so render the two states independently.
          properties:
            id:
              type: integer
            title:
              type: string
            status:
              type: string
              enum:
              - draft
              - published
              - archived
              description: The policy's own lifecycle state.
            requires_acknowledgment:
              type: boolean
              description: |
                Whether the policy asks to be accepted at all. When false the
                card should name the policy without promising an accept action.
                (Spelled `acknowledgment`, matching the Policy Hub column.)
            accepted:
              type: boolean
              description: |
                Whether this caller has accepted the CURRENT version. A policy
                HR has flagged for re-acknowledgment reads `false` here even
                though an older acceptance row exists — the reader owes a fresh
                acceptance.
            accepted_at:
              type: string
              format: date-time
              nullable: true
              description: When they accepted. `null` unless `accepted` is true.
            url:
              type: string
              nullable: true
              description: |
                Absolute URL of the mobile Policy Hub screen — the one surface
                that both reads and accepts, i.e. the target of the "Read and
                accept" action. `null` when the policy is no longer published,
                or Policy Hub isn't reachable by THIS caller, so a client never
                renders a link that only bounces.
            acknowledge_url:
              type: string
              nullable: true
              description: |
                Absolute URL to POST (empty body) to accept the policy in place
                — `POST /api/v1/policy_hub/policies/{id}/acknowledge` — so a
                card can offer the accept action itself instead of only bouncing
                the reader out to `url`. Idempotent; on success the endpoint
                returns the acknowledgment record.

                `null` whenever that POST would be refused for a reason this
                payload already knows: the policy is no longer published or
                Policy Hub isn't reachable by THIS caller, the policy doesn't
                ask to be accepted (`requires_acknowledgment: false`), it
                requires an e-signature on an e-signature-enabled tenant (where
                signing happens on the web app via `url`), or the policy is not
                this caller's to accept — no acknowledgment row assigning it and
                the caller is outside the policy's live audience, which the
                endpoint refuses with 403. That last case is why `url` can be
                non-null while this is `null`: the Policy Hub screen is readable
                by anyone, and it hides its own accept CTA on the same
                condition, so a card must not offer an accept action there.

                Stays non-null once `accepted` is true — the endpoint is
                idempotent, and a reader HR has flagged for re-acknowledgment
                reads `accepted: false` and needs it again. Also stays non-null
                when an acknowledgment row exists but the policy's audience was
                narrowed afterwards: the row IS the assignment and the endpoint
                honors it, or the reader would be stranded with a pending item
                they can never clear.

                One refusal is deliberately NOT reflected here: incomplete
                required training → 422 `training_required`. That is "blocked
                pending your action", not "not yours" — the web renders a
                disabled "Complete Training First" CTA rather than hiding it —
                so POST and surface the endpoint's message, which names the
                courses.
            acknowledge_url_reason:
              type: string
              nullable: true
              enum:
              - not_published
              - app_unavailable
              - not_applicable
              - esignature_required
              - not_targeted
              description: |
                Why there is no in-place accept action — non-null EXACTLY when
                `acknowledge_url` is `null`, and `null` whenever `acknowledge_url`
                is present. A machine-readable code drawn from the acknowledge
                endpoint's own refusal vocabulary, so a client can branch on the
                same reason whether it reads it here or POSTs and reads the error:
                  * `not_published` — the policy is no longer published (the
                    endpoint answers 404).
                  * `app_unavailable` — the policy is published but Policy Hub is
                    not reachable by THIS caller (403 `app_forbidden`).
                  * `not_applicable` — the policy does not ask to be accepted
                    (`requires_acknowledgment: false`; 422 `not_applicable`).
                  * `esignature_required` — an e-signature policy on an
                    e-signature-enabled tenant; sign from the web app via `url`
                    (422 `esignature_required`).
                  * `not_targeted` — the policy is not this caller's to accept:
                    no acknowledgment row assigns it and the caller is outside
                    the policy's live audience (403 `forbidden`). This is the
                    case where `url` can be non-null while `acknowledge_url` is
                    `null`.
                Incomplete required training is NOT surfaced here — it leaves
                `acknowledge_url` non-null on purpose (see that field).
          required:
          - id
          - title
          - status
          - requires_acknowledgment
          - accepted
          - accepted_at
          - url
          - acknowledge_url
          - acknowledge_url_reason
          example:
            id: 12
            title: Heat Safety Procedure v4
            status: published
            requires_acknowledgment: true
            accepted: false
            accepted_at:
            url: https://acme.workforce.mangoapps.com/m/apps/policy-hub/policies/12
            acknowledge_url: https://acme.workforce.mangoapps.com/api/v1/policy_hub/policies/12/acknowledge
            acknowledge_url_reason:
        topics:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
        discussion_open:
          type: boolean
        discussion_close:
          nullable: true
          description: |
            Moderation close metadata. `null` while the discussion is open
            (`discussion_open: true`); otherwise the active DiscussionClose,
            so clients can render the "Discussion was closed by … on …"
            banner. `closed_by` is `null` for an auto-close (the scheduled
            job has no actor).
          type: object
          properties:
            closed_at:
              type: string
              format: date-time
            closed_by:
              nullable: true
              "$ref": "#/components/schemas/NewsFeedUserRef"
        muted:
          type: boolean
          description: |
            True when the caller has muted notifications for this feed
            (NewsFeed::PostNotificationMute row exists for the current user).
        reactions:
          "$ref": "#/components/schemas/NewsFeedReactionsAggregate"
        comments_count:
          type: integer
          minimum: 0
          description: Total active comments (top-level + replies) on this feed.
        last_comment:
          nullable: true
          description: |
            Compact preview of the feed's most recent active comment, for the
            list card. `null` when the feed has no comments. The full thread
            (replies, reactions, attachments) loads on demand from
            `GET /feeds/{id}/comments`.
          "$ref": "#/components/schemas/NewsFeedLastComment"
        read_count:
          type: integer
          minimum: 0
          description: Total distinct readers (ReadRecord rows) on this feed.
        acknowledged_count:
          type: integer
          minimum: 0
          description: |
            Total acknowledgements (AcknowledgementRecord rows). Meaningful
            on must-read feeds; always 0 on non-must-read.
        has_correct_answer:
          type: boolean
          description: Question posts only
        correct_answer:
          nullable: true
          "$ref": "#/components/schemas/NewsFeedCorrectAnswer"
        poll:
          nullable: true
          description: Present on Poll feeds (content_type=poll). Option labels (`options[].id/text/position`)
            are returned on both list and detail; per-option vote counts (`options[].votes`)
            are detail-only.
          "$ref": "#/components/schemas/NewsFeedPoll"
        viewer:
          type: object
          description: |
            Caller-specific read/acknowledgement state, returned on every
            list row so mobile cards can render the unread indicator and
            must-read acknowledgement badge without a second roundtrip.
          properties:
            read:
              type: boolean
              description: |
                Whether this feed is in the caller's READ bucket. This is the
                exact negation of the predicate behind the unread badge
                (`GET /api/v1/apps` → communications `unread_count`), this
                endpoint's `meta.unread_counts`, `filter=unread` and the
                unread-first ordering — so a card rendered from `read` can
                never disagree with the badge.

                `false` in two cases: the caller has never opened the feed
                (`read_at` is null), or somebody else has commented since the
                caller last opened the discussion (`unread_comment_count > 0`,
                `read_at` non-null). Use `read_at.present?` — not `read` — for
                "has this person ever opened it", and note a feed can return
                `read: false` with a non-null `read_at`.

                A feed drops back to `read: true` when the caller opens the
                discussion (`GET /api/v1/feeds/{feed_id}/comments`, which
                advances the comment-read watermark) or via
                `POST /api/v1/feeds/mark_all_read`. Marking the card seen
                (`POST /api/v1/feeds/{id}/mark_seen`) records the impression
                but deliberately does NOT clear comment-driven unread.
            read_at:
              type: string
              format: date-time
              nullable: true
              description: |
                First-view read receipt — when the caller first opened this
                feed, or null if they never have. Unaffected by later comment
                activity (see `read`).
            last_seen_at:
              type: string
              format: date-time
              nullable: true
              description: |
                Most recent view of the card (bumped by
                `POST /api/v1/feeds/{id}/mark_seen` on every impression).
                Impression telemetry only — it is NOT the watermark that
                drives `read` / `unread_comment_count`.
            acknowledged:
              type: boolean
            acknowledged_at:
              type: string
              format: date-time
              nullable: true
            unread_comment_count:
              type: integer
              minimum: 0
              description: |
                Number of comments added to this feed since the caller last
                opened its DISCUSSION (created after `comments_read_at`,
                falling back to `read_at`; excluding the caller's own
                comments). `0` when the caller has never opened the feed —
                mirrors the web "N new replies" card, which only appears
                after the first view.

                Deliberately NOT keyed off `last_seen_at`: scrolling a card
                past the viewport must not silently clear replies the caller
                has not read. This is the same watermark `read` and the
                unread badge use, so the three cannot drift.
        media:
          type: array
          description: |
            Feed-post attachments (image / gif / video / file / link_preview).
            Returned on both list rows and the detail endpoint so cards can
            render thumbnails / play buttons without a second roundtrip.
            Empty array when the post has no attachments. Same shape as
            comment attachments — see NewsFeedFeedMedia.
          items:
            "$ref": "#/components/schemas/NewsFeedFeedMedia"
      required:
      - id
      - content_type
      - content_category
      - priority
      - status
      - audience_type
      - author_id
      - author
      - reactions
      - comments_count
      - read_count
      - acknowledged_count
      - muted
      - viewer
      - media
    NewsFeedLastComment:
      type: object
      nullable: true
      description: |
        Compact preview of a feed's most recent active comment, embedded on
        list rows (`NewsFeedSummary.last_comment`). Intentionally minimal —
        the full comment object (replies, reactions, attachments,
        is_correct_answer) is returned by `GET /feeds/{id}/comments`.
      properties:
        id:
          type: integer
        author:
          "$ref": "#/components/schemas/NewsFeedUserRef"
        body:
          type: string
          description: Comment text. Empty string for a soft-deleted comment.
        created_at:
          type: string
          format: date-time
      required:
      - id
      - author
      - body
      - created_at
    NewsFeedUserRef:
      type: object
      nullable: true
      description: |
        Author / user reference shape used across the News Feed API. Mirrors the
        `author` block on `NewsFeedComment` so mobile clients have a single
        renderer. `avatar_url` is the resolved CDN URL (helpers.avatar_url) and
        may point to a default avatar when the user hasn't uploaded one.
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
          format: uri
    NewsFeedReactionsAggregate:
      type: object
      description: |
        Aggregate reactions (from `NewsFeed::ReactionsService.aggregate`).
        `my_reaction` is the caller's own emoji_key or null.
      properties:
        total:
          type: integer
          minimum: 0
        top_emojis:
          type: array
          items:
            type: object
            properties:
              emoji_key:
                type: string
              count:
                type: integer
                minimum: 0
        my_reaction:
          type: string
          nullable: true
    NewsFeedDetail:
      allOf:
      - "$ref": "#/components/schemas/NewsFeedSummary"
      - type: object
        properties:
          rendered_body:
            type: string
            description: Server-rendered Markdown HTML for the body
          poll:
            nullable: true
            "$ref": "#/components/schemas/NewsFeedPoll"
          ai_summary:
            nullable: true
            allOf:
            - "$ref": "#/components/schemas/NewsFeedDiscussionSummary"
            description: |
              PRD 14 §11 — Embedded AI discussion summary. Mirrors the
              `_discussion_summary_card.html.erb` web partial: returned
              when the tenant has `ai_discussion_summarization` enabled
              and a row exists for the feed, else `null`. Both
              `succeeded` and `failed` rows are surfaced so clients can
              render the same "couldn't be generated" fallback the web
              card shows. Detail-only — list rows omit this field.
          poll_summary:
            nullable: true
            allOf:
            - "$ref": "#/components/schemas/NewsFeedPollSummary"
            description: |
              PRD 15 §11 — Embedded AI poll outcome summary. Mirrors the
              `_poll_summary_card.html.erb` web partial: returned only
              when the tenant has `ai_poll_summarization` enabled, the
              poll is closed, and the latest summary `succeeded`. Failed
              rows are admin-visible only and surface as `null` here.
              Detail-only and present only on `content_type=poll` feeds.
    NewsFeedDiscussionSummary:
      type: object
      description: |
        PRD 14 §11 — JSON view of `NewsFeed::DiscussionSummary`. Returned
        embedded on `NewsFeedDetail.ai_summary` and standalone from
        `GET /feeds/{id}/summary`.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        status:
          type: string
          enum:
          - succeeded
          - failed
        trigger:
          type: string
          enum:
          - threshold
          - manual_close
          - author_request
        comment_count_at_generation:
          type: integer
          minimum: 0
        generated_at:
          type: string
          format: date-time
        model_version:
          type: string
          nullable: true
        failure_reason:
          type: string
          nullable: true
          description: Populated when status == failed.
        summary:
          type: object
          description: |
            Structured payload extracted from `summary_payload`. Always
            present (even when `status == failed`, in which case fields
            below may be blank / default values). `decisions` defaults to
            `"None identified"` when the underlying payload field is
            blank — mirrored from `NewsFeed::DiscussionSummary#decisions`
            so the web view and the API agree without duplicating copy.
          properties:
            takeaways:
              type: array
              items:
                type: string
            decisions:
              type: string
            action_items:
              type: array
              items:
                type: object
                properties:
                  name:
                    type: string
                    nullable: true
                  action:
                    type: string
                  deadline:
                    type: string
                    nullable: true
            sentiment:
              type: string
              nullable: true
              enum:
              - positive
              - neutral
              - negative
              - mixed
            sentiment_reason:
              type: string
              nullable: true
            question_answer_status:
              type: string
              nullable: true
            correct_answer_excerpt:
              type: string
              nullable: true
    NewsFeedPollSummary:
      type: object
      description: |
        PRD 15 §11 — JSON view of `NewsFeed::PollSummary`. Returned
        embedded on `NewsFeedDetail.poll_summary` and standalone from
        `GET /feeds/{id}/poll_summary`.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        status:
          type: string
          enum:
          - succeeded
          - failed
        trigger:
          type: string
          enum:
          - poll_close
          - admin_replay
        generated_at:
          type: string
          format: date-time
        model_version:
          type: string
          nullable: true
        failure_reason:
          type: string
          nullable: true
        summary_text:
          type: string
          nullable: true
          maxLength: 500
          description: Human-readable 2-3 sentence outcome summary (PRD §10 — max
            500 chars). Null when status == failed.
        summary:
          type: object
          properties:
            leading_option_id:
              type: integer
              nullable: true
            leading_option_text:
              type: string
              nullable: true
            winning_percentage:
              type: number
              nullable: true
            participation_rate:
              type: number
              description: Fraction in [0, 1].
            low_participation:
              type: boolean
            is_anonymous:
              type: boolean
            segment_patterns:
              type: array
              items:
                type: object
                additionalProperties: true
    NewsFeedCorrectAnswer:
      type: object
      description: |
        Verified-answer block returned on Question posts that have a marked
        correct answer. Returned both embedded on `NewsFeedSummary.correct_answer`
        and standalone from `GET /feeds/{id}/correct_answer`. `marked_by_user_id`
        identifies the admin / author who **marked** the answer as verified;
        `answer.author` is the user who actually **wrote** the answer comment.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        comment_id:
          type: integer
        marked_at:
          type: string
          format: date-time
        marked_by_user_id:
          type: integer
        answer:
          nullable: true
          type: object
          description: |
            The marked answer comment + the user who wrote it. Mirrors the
            web "Correct Answer" pinned card. `null` when the comment has
            been soft-deleted or hard-deleted while the outer
            `correct_answer` record persists — mirrors the web partial
            which renders a "[the marked comment is no longer available]"
            placeholder in that case. The outer `comment_id` /
            `marked_at` / `marked_by_user_id` are still returned for audit.
          properties:
            id:
              type: integer
              description: ID of the marked comment.
            body:
              type: string
              description: Comment body as authored (plain text per PRD §13).
            status:
              type: string
              enum:
              - active
              - deleted
            created_at:
              type: string
              format: date-time
            edited_at:
              type: string
              format: date-time
              nullable: true
            author:
              "$ref": "#/components/schemas/NewsFeedUserRef"
              description: |
                User who wrote the marked answer. Distinct from
                `marked_by_user_id`, which is the user who marked it as
                verified (typically the post author or an admin).
    NewsFeedPoll:
      type: object
      description: |
        Poll state embedded on Poll-type feeds. Returned on both list rows
        and the detail endpoint. Option labels (`options[].id/text/position`)
        are included on both so cards can render the option list from the
        list response; per-option vote counts (`options[].votes`) are
        detail-only to keep list payloads light.
      properties:
        closes_at:
          type: string
          format: date-time
          nullable: true
        voting_mode:
          type: string
          nullable: true
          enum:
          - single
          - multi
          - ranked
          description: |
            Controls how `my_vote_option_ids` should be interpreted:
              * `single` — at most 1 element
              * `multi`  — 0..N elements, order is not meaningful
              * `ranked` — 0..N elements, ordered by rank (most-preferred first)
        is_anonymous:
          type: boolean
          nullable: true
        result_visibility:
          type: string
          nullable: true
          enum:
          - live
          - hidden_until_close
        allow_change_vote:
          type: boolean
          nullable: true
        allow_comments:
          type: boolean
          description: |
            Whether a comment may be posted on the poll right now
            (`Feed#comments_allowed?`). Folds the author's create-time
            comment setting (the feed-level `comments_enabled` column) together
            with the moderation discussion-close state, so poll cards gate their
            comment input from one field. Set it at create/update time via
            `poll_config_attributes[allow_comments]` (or top-level
            `feed[allow_comments]`).
        is_closed:
          type: boolean
          description: True when poll_config.closes_at has elapsed.
        option_count:
          type: integer
          minimum: 0
          description: Number of poll options.
        my_vote_option_ids:
          type: array
          description: |
            The caller's voted option_ids. Always present; empty array when
            the caller has not voted. Interpret via `voting_mode`:
              * `single` — at most 1 element
              * `multi`  — 0..N elements, order not meaningful
              * `ranked` — 0..N elements, ordered by rank (most-preferred first)
          items:
            type: integer
        results_visible:
          type: boolean
          description: |
            Detail-only. True when the caller is allowed to see the
            per-option vote breakdown; false when results are gated.

            Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
            surface:
              * Closed poll → true (everyone)
              * Author or business admin → true (always)
              * Open + `result_visibility: hidden_until_close` → false
              * Open + `result_visibility: live`, caller has voted → true
              * Open + `result_visibility: live`, caller has not voted → false

            When false, `total_votes` and each `options[].votes` /
            `options[].percent` are returned as `null` so clients can render
            a "results hidden" state without inferring whether anyone has
            voted yet.
        total_votes:
          type: integer
          minimum: 0
          nullable: true
          description: |
            Aggregate count of distinct voters (sum of active PollVote rows).
            Detail-only — returned on `GET /feeds/{id}` and on the
            `/feeds/{id}/poll_votes` responses, omitted from list rows.
            `null` when `results_visible` is false.
        options:
          type: array
          description: |
            Poll options. `id`, `text`, and `position` are returned on both
            list rows and the detail endpoint. `votes` (per-option count)
            and `percent` (0–100, rounded) are detail-only — list rows omit
            them. Both are `null` when `results_visible` is false.
          items:
            type: object
            properties:
              id:
                type: integer
              text:
                type: string
              position:
                type: integer
              votes:
                type: integer
                nullable: true
                description: Detail endpoint only. `null` when results are gated.
              percent:
                type: integer
                minimum: 0
                maximum: 100
                nullable: true
                description: |
                  Detail endpoint only. Share of `total_votes` cast for this
                  option, rounded to the nearest integer (0–100). `null` when
                  results are gated. Matches the value rendered by the web
                  `_poll` partial so mobile and web stay in lockstep.
    NewsFeedComment:
      type: object
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        parent_comment_id:
          type: integer
          nullable: true
        depth:
          type: integer
          minimum: 0
          maximum: 1
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            avatar_url:
              type: string
              nullable: true
        body:
          type: string
          description: |
            Comment text. Empty string for soft-deleted comments and for
            attachment-only comments (the server stores a zero-width-space
            sentinel which the serializer surfaces verbatim — clients should
            treat `body.trim()` as the display value).
        status:
          type: string
          enum:
          - active
          - deleted
        edited_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        topics:
          type: array
          description: |
            Topics assigned to this comment — author hashtags (`#tag` in the
            body) plus AI-classified topics. At most 3, ordered by assignment
            time. Same shape as a feed post's `topics`. A comment carrying a
            topic also makes its parent feed match that topic in
            `GET /feeds?topic_id=`.
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
        replies_count:
          type: integer
        replies:
          type: array
          description: |
            Direct child comments (depth-1 replies) inlined on top-level
            comments. Each reply is a full `NewsFeedComment` payload —
            same shape, including its own `media[]` for attachments.
            Always empty (`[]`) on a reply, since `MAX_DEPTH = 1`.
            Ordered by `created_at` ascending.
          items:
            "$ref": "#/components/schemas/NewsFeedComment"
        reactions:
          "$ref": "#/components/schemas/NewsFeedReactionsAggregate"
        media:
          type: array
          description: |
            Attachments on this comment (PRD 04 Phase 16). Empty when the
            comment has no media. Same shape as feed-post attachments.
          items:
            "$ref": "#/components/schemas/NewsFeedFeedMedia"
        is_correct_answer:
          type: boolean
    NewsFeedFeedMedia:
      type: object
      description: |
        FeedMediaSerializer payload — used for both feed-post and comment
        attachments. `feed_id` is set once the row is claimed onto a post;
        `comment_id` is set once the row is claimed onto a comment. While
        the row is still orphan (uploaded but not yet attached), both are
        null.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
          nullable: true
        comment_id:
          type: integer
          nullable: true
        media_type:
          type: string
          enum:
          - image
          - gif
          - video
          - file
          - link_preview
        processing_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        original_filename:
          type: string
          nullable: true
        mime_type:
          type: string
          nullable: true
        file_size_bytes:
          type: integer
          nullable: true
        storage_url:
          type: string
          nullable: true
          description: Presigned S3 URL or CDN URL — short-lived.
        hls_playlist_url:
          type: string
          nullable: true
          description: Set for transcoded videos.
        thumbnail_url:
          type: string
          nullable: true
        alt_text:
          type: string
          nullable: true
        width_px:
          type: integer
          nullable: true
        height_px:
          type: integer
          nullable: true
        position:
          type: integer
        created_at:
          type: string
          format: date-time
    SafetyHubPaginationMeta:
      type: object
      description: |
        Pagination metadata as returned by Api::V1::SafetyHubController#pagination_meta.
        Field shape differs from the global PaginationMeta (this one uses `page`/`total`,
        the global uses `current_page`/`total_count`).
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total:
          type: integer
          example: 137
        total_pages:
          type: integer
          example: 6
      required:
      - page
      - per_page
      - total
      - total_pages
    SafetyHubPersonRef:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    SafetyHubLocationRef:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    Incident:
      type: object
      description: Safety Hub incident (list serializer)
      properties:
        id:
          type: integer
          example: 117
        reference:
          type: string
          example: INC-117
          description: Human-facing reference ("INC-<id>")
        title:
          type: string
          example: Slip on wet floor
        incident_type:
          type: string
          enum:
          - injury
          - near_miss
          - property_damage
          - environmental
          - security
          - vehicle_accident
          - equipment_failure
        severity:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        status:
          type: string
          enum:
          - reported
          - investigating
          - investigation_completed
          - closed
          - cancelled
        occurred_at:
          type: string
          format: date-time
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        reporter:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        anonymous:
          type: boolean
        osha_recordable:
          type: boolean
    IncidentDetail:
      description: Safety Hub incident (detail serializer — adds investigation fields)
      allOf:
      - "$ref": "#/components/schemas/Incident"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          immediate_actions:
            type: string
            nullable: true
          location_specific_notes:
            type: string
            nullable: true
            description: Sub-location / area within the location
          osha_status:
            type: string
            enum:
            - recordable
            - not_recordable
            - under_review
            - not_applicable
            description: Recordability label; "under_review" until a determination
              is stamped, "not_applicable" for a non-injury incident (never an OSHA
              candidate)
          investigator:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          witnesses:
            type: array
            description: Witness roster. PRIVILEGED — empty for a plain reporter;
              only a site manager or the assigned investigator sees names (matches
              the desktop Witnesses card).
            items:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
          investigation_due_at:
            type: string
            format: date-time
            nullable: true
          investigation_started_at:
            type: string
            format: date-time
            nullable: true
          investigation_completed_at:
            type: string
            format: date-time
            nullable: true
          investigation_overdue:
            type: boolean
            description: True when the investigation deadline has passed and the investigation
              is neither completed nor the incident closed/cancelled.
          media:
            type: array
            description: Incident scene photos & videos ("Photos & Videos" section),
              in display order. Visible to any viewer who can reach the record.
            items:
              "$ref": "#/components/schemas/SafetyHubMediaItem"
          investigation:
            nullable: true
            description: Latest investigation narrative. Null unless the caller is
              a PII viewer (site manager or assigned investigator).
            type: object
            properties:
              id:
                type: integer
              investigation_type:
                type: string
                nullable: true
              status:
                type: string
              summary:
                type: string
                nullable: true
              root_cause:
                type: string
                nullable: true
              findings:
                type: string
                nullable: true
              recommendations:
                type: string
                nullable: true
              started_at:
                type: string
                format: date-time
                nullable: true
              completed_at:
                type: string
                format: date-time
                nullable: true
          corrective_actions:
            type: array
            description: CAPA register for this incident (see SafetyHubCorrectiveAction
              schema)
            items:
              "$ref": "#/components/schemas/SafetyHubCorrectiveAction"
          activity_count:
            type: integer
            description: Number of activity-timeline entries (incident updates).
          activity:
            type: array
            description: |
              Activity timeline (incident updates), newest first — the detail
              screen's "Activity" card. NOT privileged: visible to any viewer
              who can reach the record, matching the desktop show page.
            items:
              type: object
              properties:
                id:
                  type: integer
                type:
                  type: string
                  description: Raw update_type (status_change, investigation_note,
                    evidence_added, witness_statement, corrective_action, follow_up,
                    closure).
                type_label:
                  type: string
                  description: Human label for the update type.
                content:
                  type: string
                created_at:
                  type: string
                  format: date-time
                user:
                  nullable: true
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    initials:
                      type: string
          compliance:
            nullable: true
            description: |
              OSHA/WCB determination record backing the "OSHA" detail row and
              the manager "Determine OSHA / WCB Reportability" workflow.
              PRIVILEGED — null unless the caller is a PII viewer (site manager
              or assigned investigator), matching the desktop OSHA + WCB cards.
              The `wcb` sub-block is present only when the tenant has WCB
              compliance enabled.
            type: object
            properties:
              is_injury:
                type: boolean
              osha_recordable:
                type: boolean
                nullable: true
              osha_classification:
                type: string
                nullable: true
                description: e.g. "Fatality", "Days Away From Work", "Job Transfer
                  or Restriction", "Other Recordable Cases". Null until recordable.
              fatality:
                type: boolean
              days_away_from_work:
                type: integer
              days_restricted_work:
                type: integer
              medical_attention_people:
                type: array
                description: Involved people flagged as needing medical attention.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
              wcb:
                type: object
                description: Present only when WCB compliance is enabled for the tenant.
                properties:
                  reportable:
                    type: boolean
                    nullable: true
                  classification:
                    type: string
                    nullable: true
                  province:
                    type: string
                    nullable: true
                  reporting_deadline:
                    type: string
                    format: date
                    nullable: true
                  claim_number:
                    type: string
                    nullable: true
          closed_at:
            type: string
            format: date-time
            nullable: true
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    SafetyHubIncidentPermissions:
      type: object
      description: |
        Viewer-relative capability flags for the incident detail screen, so a
        client renders the right chrome without a second round trip. The
        detail screen's only persona split is the manager-only "Investigation
        Workflow" block.
      properties:
        can_view_investigation:
          type: boolean
          description: Viewer may see the investigation narrative (findings/root cause/recommendations)
            — a site manager or the assigned investigator.
        can_manage_investigation:
          type: boolean
          description: Viewer may run the Investigation Workflow (reassign/complete/close/determine
            OSHA-WCB) — a site manager for the incident.
        can_edit:
          type: boolean
          description: Viewer may edit the incident (reporter or site manager, and
            the incident is in an editable state).
        can_close:
          type: boolean
          description: Viewer may close the incident now (a site manager, and nothing
            blocks closure). False for non-managers and whenever close_blocker is
            set.
        close_blocker:
          type: string
          nullable: true
          description: |
            User-facing reason the incident cannot be closed yet (the mockup's
            "Close Incident" blocker line), or null when it can. Computed only
            for a site manager; always null for other viewers.
        is_reporter:
          type: boolean
        is_investigator:
          type: boolean
    SafetyHubIncidentCreateRequest:
      type: object
      description: |
        Body for reporting a new incident (POST /safety_hub/incidents). Wrapped
        under an `incident` key. Optional people/witness rows sit alongside it at
        the top level, mirroring the web form. Photos are sent as multipart
        `photos[]` (see the endpoint's multipart schema), never inside this
        object.
      required:
      - incident
      properties:
        incident:
          type: object
          required:
          - title
          - description
          - incident_type
          - severity
          - occurred_at
          properties:
            title:
              type: string
              minLength: 3
              maxLength: 255
              example: Forklift clipped a rack leg
            description:
              type: string
              minLength: 10
              maxLength: 5000
            incident_type:
              type: string
              enum:
              - injury
              - near_miss
              - property_damage
              - environmental
              - security
              - vehicle_accident
              - equipment_failure
              - other
            severity:
              type: string
              enum:
              - low
              - medium
              - high
              - critical
            occurred_at:
              type: string
              format: date-time
              description: When the incident occurred (must not be in the future).
            location_id:
              type: integer
              nullable: true
              description: A site in the caller's business; a foreign id is dropped
                rather than rejected.
            immediate_actions:
              type: string
              nullable: true
            anonymous:
              type: boolean
              default: false
              description: When true, the reporter identity is withheld from the response
                and every read surface.
            confidential:
              type: boolean
              default: false
            days_away_from_work:
              type: integer
              nullable: true
              minimum: 0
              maximum: 9999
            days_restricted_work:
              type: integer
              nullable: true
              minimum: 0
              maximum: 9999
            fatality:
              type: boolean
              default: false
            wcb_province:
              type: string
              nullable: true
            alert_id:
              type: integer
              nullable: true
              description: Optional emergency Alert this report opened from; a foreign
                id is dropped.
        people:
          type: array
          description: Optional people-involved rows. A row that fails validation
            is returned as a warning, not an error.
          items:
            type: object
            properties:
              name:
                type: string
              person_type:
                type: string
                enum:
                - employee
                - contractor
                - visitor
                - customer
              role:
                type: string
                nullable: true
              contact_info:
                type: string
                nullable: true
              injury_type:
                type: string
                enum:
                - none
                - first_aid
                - medical_treatment
                - hospitalization
                nullable: true
              body_parts_affected:
                type: string
                nullable: true
              medical_attention:
                type: boolean
                nullable: true
        witnesses:
          type: array
          description: Optional witness rows. A row that fails validation is returned
            as a warning, not an error.
          items:
            type: object
            properties:
              name:
                type: string
              contact_info:
                type: string
                nullable: true
              relationship_to_incident:
                type: string
                nullable: true
              statement:
                type: string
                nullable: true
    SafetyObservation:
      type: object
      description: Safety observation (list serializer)
      properties:
        id:
          type: integer
          example: 44
        observation_type:
          type: string
          enum:
          - positive
          - at_risk
          - near_miss
        category:
          type: string
          example: PPE
        status:
          type: string
          enum:
          - submitted
          - under_review
          - resolved
          - closed
        description:
          type: string
          nullable: true
          description: Observation headline/body (observations have no separate title)
        observed_at:
          type: string
          format: date-time
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        observer:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        anonymous:
          type: boolean
        follow_up_required:
          type: boolean
    SafetyObservationDetail:
      description: |
        Safety observation (detail serializer). Adds the follow-up fields, the
        attached photos & videos gallery (`media`), and the drive the
        observation was filed against (`campaign`). Viewable by the observer who
        submitted it, or by a Safety Hub manager whose accessible sites include
        the observation's site (a site-less observation stays manager-visible).
      allOf:
      - "$ref": "#/components/schemas/SafetyObservation"
      - type: object
        properties:
          specific_location:
            type: string
            nullable: true
            description: Sub-location / area within the location
          action_taken:
            type: string
            nullable: true
          follow_up_notes:
            type: string
            nullable: true
          follow_up_completed_at:
            type: string
            format: date-time
            nullable: true
          follow_up_completed_by:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          campaign:
            "$ref": "#/components/schemas/SafetyObservationCampaignRef"
          media:
            type: array
            description: Attached photos & videos, in display order. Empty when none
              are attached.
            items:
              "$ref": "#/components/schemas/SafetyHubMediaItem"
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    SafetyObservationCreate:
      type: object
      description: |
        Fields for submitting a safety observation (POST /safety_hub/observations).
        Only observation_type, category and description are required; observed_at
        defaults to now when omitted. `status` cannot be set — a new observation
        always starts `submitted`. A location_id / safety_observation_campaign_id
        the caller's tenant does not own is dropped rather than rejected.
      required:
      - observation_type
      - category
      - description
      properties:
        observation_type:
          type: string
          enum:
          - positive
          - at_risk
          - near_miss
          description: positive = safe behaviour to reinforce; at_risk / near_miss
            both notify managers + safety officers.
        category:
          type: string
          example: Housekeeping
          description: Free-form category from the tenant's configured list.
        description:
          type: string
          maxLength: 5000
          description: What did you observe? (the headline — observations have no
            separate title).
        observed_at:
          type: string
          format: date-time
          description: When the observation was made. Defaults to now when omitted;
            cannot be in the future.
        specific_location:
          type: string
          maxLength: 255
          description: Sub-location / area within the location ("Bay 3 — mixing station").
        location_id:
          type: integer
          description: A location owned by the caller's business; a foreign id is
            dropped.
        action_taken:
          type: string
          description: Immediate action the observer took (optional).
        follow_up_required:
          type: boolean
          description: Keep the observation open until it is closed out.
        follow_up_notes:
          type: string
          description: Follow-up detail (optional).
        safety_observation_campaign_id:
          type: integer
          description: An active observation drive owned by the caller's business;
            a foreign id is dropped.
    SafetyHubMediaItem:
      type: object
      description: |
        A photo or video attached to a Safety Hub record. Same shape as the
        inspections media payload; `url` is an absolute, signed download URL
        (1-hour expiry) and blanks to null only for a purged/unattached blob.
      properties:
        id:
          type: integer
        media_kind:
          type: string
          enum:
          - photo
          - video
          - voice_memo
          example: photo
        content_type:
          type: string
          example: image/jpeg
        byte_size:
          type: integer
          nullable: true
          example: 2097152
        original_filename:
          type: string
          nullable: true
          example: walkway-hazard.jpg
        url:
          type: string
          nullable: true
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/abc/walkway-hazard.jpg
        thumb_url:
          type: string
          nullable: true
          example: https://acme.workforce.mangoapps.com/rails/active_storage/representations/.../walkway-hazard.jpg
        captured_at:
          type: string
          format: date-time
          nullable: true
      required:
      - id
      - media_kind
    SafetyObservationCampaignRef:
      type: object
      nullable: true
      description: |
        The observation drive this observation was filed against, or null when
        unattributed. Disclosure only — the reward is surfaced so the reporter
        knows the drive offers one; nothing in this API awards it.
      properties:
        id:
          type: integer
          example: 12
        name:
          type: string
          example: Q3 Housekeeping Drive
        reward:
          type: object
          nullable: true
          description: Present only when the drive enables a reward with a description
            or a positive point value.
          properties:
            description:
              type: string
              nullable: true
              example: Coffee voucher
            points:
              type: integer
              nullable: true
              example: 50
    ToolboxTalk:
      type: object
      description: Toolbox talk (list serializer)
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - scheduled
          - in_progress
          - completed
          - cancelled
        scheduled_at:
          type: string
          format: date-time
        duration_minutes:
          type: integer
          nullable: true
        facilitator:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        topic:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            title:
              type: string
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        meeting_location:
          type: string
          nullable: true
    ToolboxTalkDetail:
      description: Toolbox talk (detail serializer — adds completion + attendance)
      allOf:
      - "$ref": "#/components/schemas/ToolboxTalk"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          notes:
            type: string
            nullable: true
          started_at:
            type: string
            format: date-time
            nullable: true
          completed_at:
            type: string
            format: date-time
            nullable: true
          actual_duration_minutes:
            type: integer
            nullable: true
          attendance_count:
            type: integer
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    ToolboxTalkTopic:
      type: object
      description: Toolbox talk topic template
      properties:
        id:
          type: integer
        title:
          type: string
        category:
          type: string
        duration_estimate:
          type: integer
          nullable: true
        content_preview:
          type: string
          nullable: true
        system_template:
          type: boolean
        times_used:
          type: integer
        updated_at:
          type: string
          format: date-time
    SafetyCertificationRequirement:
      type: object
      description: |
        Safety Hub certification REQUIREMENT (template). Distinct from the global
        `Certification` schema, which represents an employee's earned certification.
      properties:
        id:
          type: integer
        name:
          type: string
        category:
          type: string
        description:
          type: string
          nullable: true
        mandatory:
          type: boolean
        active:
          type: boolean
        validity_period_months:
          type: integer
          nullable: true
        renewal_reminder_days:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SafetyHubCorrectiveAction:
      type: object
      description: |
        One row from GET /safety_hub/corrective_actions — a corrective/preventive
        action (Capa::Action), shaped for the mobile "My Corrective Actions" list
        and detail. Priority/status carry the same Bootstrap color tokens the web
        badges use, so a native client renders the identical treatment.
      properties:
        id:
          type: integer
        summary:
          type: string
          description: One-line, markdown-stripped rendering of the description (the
            web list title).
        description:
          type: string
          description: Full action description.
        action_type:
          type: string
          nullable: true
          description: Raw action type (immediate/short_term/long_term/preventive/…),
            null when unset.
        action_type_label:
          type: string
          description: Display label for the action type ("Corrective" when unset).
        priority:
          type: object
          properties:
            value:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
            color:
              type: string
              description: Bootstrap color token (danger/warning/info/secondary)
        status:
          type: object
          properties:
            value:
              type: string
              nullable: true
              description: pending/in_progress/completed/cancelled
            label:
              type: string
              nullable: true
            color:
              type: string
              description: Bootstrap color token
        source:
          type: object
          description: Where the action came from.
          properties:
            label:
              type: string
              description: Inspection / Visit Finding / Incident / Other
            type:
              type: string
              nullable: true
              description: Raw polymorphic source_type, null for a manually-raised
                action
            incident:
              type: object
              nullable: true
              description: |
                Present only for incident-sourced rows. `url` is a mobile deep
                link, but only when the caller may view the incident (a manager,
                or the incident's own reporter); otherwise `null` — the reference
                label is still shown.
              properties:
                id:
                  type: integer
                reference:
                  type: string
                  example: INC-123
                url:
                  type: string
                  nullable: true
                  example: "/m/apps/safety-hub/incidents/123"
        assigned_to:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        assignee_name:
          type: string
          description: Display string — assignee full name, a free-text role, or "Unassigned".
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        due_date:
          type: string
          format: date
          nullable: true
        overdue:
          type: boolean
          description: True for an open action past its due date.
        days_overdue:
          type: integer
          description: Days past due for an overdue action, else 0.
        completed_at:
          type: string
          format: date-time
          nullable: true
        resolution_notes:
          type: string
          nullable: true
        verified:
          type: boolean
          description: ISO 45001 effectiveness-verification state.
        verified_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SafetyHubCorrectiveActionDetail:
      allOf:
      - "$ref": "#/components/schemas/SafetyHubCorrectiveAction"
      - type: object
        description: |
          The GET /safety_hub/corrective_actions/{id} detail payload — the base
          card fields plus the show-screen additions: the ISO 45001
          effectiveness-verification record and the due-date countdown. The
          Completion card ("Completed on … / Closed out by …") reads the base
          `completed_at` and `assignee_name`; the register has no separate
          completer, so no `completed_by` field is emitted.
        properties:
          verified_by:
            allOf:
            - "$ref": "#/components/schemas/SafetyHubPersonRef"
            description: The manager who signed off effectiveness; null until verified.
          effectiveness_notes:
            type: string
            nullable: true
            description: The attestation of what was checked and why the action was
              effective.
          days_until_due:
            type: integer
            nullable: true
            description: Days until the due date (0 if due today or past, null when
              no due date).
    SafetyHubCorrectiveActionPermissions:
      type: object
      description: |
        Viewer-relative capability flags returned alongside a corrective-action
        detail, so a native client renders the right controls without a second
        round trip. Each mirrors the desktop authority exactly.
      properties:
        is_assignee:
          type: boolean
          description: True when the caller is the action's assignee.
        is_manager:
          type: boolean
          description: True when the caller is a safety-hub manager (manager-or-above
            OR the app admin).
        can_complete:
          type: boolean
          description: True when the action is OPEN and the caller is the assignee
            or a manager.
        can_assign:
          type: boolean
          description: True when the caller is a manager and the action is OPEN (offer
            the Assign control).
        can_verify:
          type: boolean
          description: |
            True when the tenant's effectiveness-verification policy is on, the
            action is completed and not yet verified, and the caller is a manager
            who is NOT the assignee (self-verification is refused).
    SafetyHubPermit:
      type: object
      description: |
        One row from GET /safety_hub/permits — a permit to work (WorkPermit),
        shaped for the mobile "My Permits" list card. `display_state` is the
        board state ("active"/"overrun" for an issued permit inside/past its
        window, else the raw status) so the client colours the status pill the
        same way the web board does.
      properties:
        id:
          type: integer
        permit_number:
          type: string
          example: PTW-2026-0042
        title:
          type: string
        permit_type:
          type: string
          description: Raw type key.
          enum:
          - hot_work
          - confined_space
          - working_at_height
          - electrical
          - excavation
          - lifting
          - general
        type_label:
          type: string
          description: Display label for the permit type (e.g. "Hot work").
        status:
          type: string
          description: Raw lifecycle status.
          enum:
          - draft
          - requested
          - approved
          - suspended
          - closed
          - cancelled
          - expired
        display_state:
          type: string
          description: |
            Board state — the raw status, except an issued permit reads
            `active` inside its work window and `overrun` past it.
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
        area:
          type: string
          nullable: true
          description: Free-text area within the site (e.g. "Bay 4").
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        vendor:
          type: object
          nullable: true
          description: The contractor/vendor performing the work, when set.
          properties:
            id:
              type: integer
            name:
              type: string
    SafetyHubPermitDetail:
      allOf:
      - "$ref": "#/components/schemas/SafetyHubPermit"
      - type: object
        description: |
          The GET /safety_hub/permits/{id} detail payload — the base card
          fields plus the show-page detail: requester (issuer) / authoriser
          (approver), the type's hazards, the precaution checklist with each
          control's confirmed state, and the closure record.
        properties:
          description:
            type: string
            nullable: true
          requested_by:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          approved_by:
            allOf:
            - "$ref": "#/components/schemas/SafetyHubPersonRef"
            description: The authoriser who issued the permit; null until approved.
          approved_at:
            type: string
            format: date-time
            nullable: true
          approval_notes:
            type: string
            nullable: true
          hazards:
            type: array
            description: The confirmed hazards for this permit.
            items:
              type: string
          precautions:
            type: array
            description: |
              The permit type's full precaution checklist, each with the
              control text and whether it has been confirmed.
            items:
              type: object
              properties:
                text:
                  type: string
                confirmed:
                  type: boolean
          precautions_complete:
            type: boolean
            description: True when every required precaution has been confirmed.
          isolations:
            type: string
            nullable: true
          ppe:
            type: string
            nullable: true
          gas_test:
            type: string
            nullable: true
          emergency_arrangements:
            type: string
            nullable: true
          suspended_reason:
            type: string
            nullable: true
          cancelled_reason:
            type: string
            nullable: true
          area_left_safe:
            type: boolean
            description: Confirmed at close-out that the area was left safe.
          closure_notes:
            type: string
            nullable: true
          closed_by:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          closed_at:
            type: string
            format: date-time
            nullable: true
          contractor_prequalification:
            nullable: true
            description: |
              The permit's "Contractor" card. Null for an own-crew permit (no
              vendor). When a vendor is present the block is always returned so
              a client can render the "no current pre-qualification" (and, when
              `required`, "cannot be issued") state without a second call.
            type: object
            properties:
              required:
                type: boolean
                description: Whether the tenant requires a current pre-qualification
                  to issue a contractor permit.
              current:
                type: boolean
                description: Whether a live (approved/conditional, unexpired) pre-qualification
                  exists for the vendor.
              status:
                type: string
                nullable: true
                description: Pre-qualification status (draft/approved/conditional/…);
                  null when there is none.
              risk_tier:
                type: string
                nullable: true
              valid_until:
                type: string
                format: date
                nullable: true
              conditions:
                type: string
                nullable: true
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    SafetyHubKbCategoryFacet:
      type: object
      description: |
        One category chip from GET /safety_hub/knowledge_base — a filter value,
        its display label, and the un-paginated number of articles behind it.
        The `all` facet carries the total across every category.
      properties:
        value:
          type: string
          example: ppe_guides
          description: The category filter value (or `all` for the total chip).
        label:
          type: string
          example: PPE Guides
        count:
          type: integer
          description: Un-paginated number of articles in this category (respecting
            the source_type filter).
    SafetyHubKnowledgeBaseEntry:
      type: object
      description: |
        One row from GET /safety_hub/knowledge_base — a safety knowledge base
        article (KnowledgeBaseEntry, domain :safety) shaped for the mobile
        "Knowledge Base" list card: source type + display label, title, the FAQ
        question as the card subtitle, category + display label, status, author,
        and timestamps.
      properties:
        id:
          type: integer
        title:
          type: string
        source_type:
          type: string
          description: Raw content-type key.
          enum:
          - faq
          - document
          - url
          - video
        source_type_label:
          type: string
          description: Display label for the source type (e.g. "Safety FAQ", "Document").
        category:
          type: string
          description: Raw category key.
        category_label:
          type: string
          description: Display label for the category (e.g. "PPE Guides").
        question:
          type: string
          nullable: true
          description: The FAQ question, shown as the card subtitle; null for non-FAQ
            types.
        status:
          type: string
          description: |
            Lifecycle status. A member only ever sees `active`; a manager also
            sees `draft`, `processing`, `failed`, `archived`, `pending_review`.
          enum:
          - draft
          - processing
          - active
          - failed
          - archived
          - pending_review
        created_by:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SafetyHubKnowledgeBaseEntryDetail:
      allOf:
      - "$ref": "#/components/schemas/SafetyHubKnowledgeBaseEntry"
      - type: object
        description: |
          The GET /safety_hub/knowledge_base/{id} detail payload — the base
          card fields plus the show-page detail: the answer / extracted body,
          any additional Q&A pairs, a safe external URL, and attached-file
          metadata.
        properties:
          answer:
            type: string
            nullable: true
            description: The FAQ answer; null for non-FAQ types.
          content:
            type: string
            nullable: true
            description: Extracted / crawled / transcribed body for document, url
              and video entries.
          description:
            type: string
            nullable: true
          faq_items:
            type: array
            description: Additional Q&A pairs beyond the primary question/answer (empty
              for legacy column-authored FAQs).
            items:
              type: object
              properties:
                question:
                  type: string
                answer:
                  type: string
          source_url:
            type: string
            nullable: true
            description: A safe absolute http(s) URL for url-sourced entries; null
              otherwise (re-validated at the sink).
          file:
            type: object
            nullable: true
            description: Attached-file metadata for a document/video entry; null when
              nothing is attached.
            properties:
              filename:
                type: string
              content_type:
                type: string
              byte_size:
                type: integer
              url:
                type: string
                description: Absolute download URL with a 1-hour signed expiry.
          minimum_role:
            type: integer
            description: Role floor for access (0=super_admin … 4=member).
    SafetyHubSubmission:
      type: object
      description: One row from GET /safety_hub/submissions — UI-ready metadata.
      properties:
        id:
          type: integer
          description: ID of the underlying incident or observation
        type:
          type: object
          description: Kind of submission plus UI metadata (icon name + color token)
          properties:
            kind:
              type: string
              enum:
              - incident
              - observation
            label:
              type: string
              example: Incident
            icon:
              type: string
              example: alert-triangle
              description: Stable icon-name token (client maps to its icon set)
            icon_color:
              type: string
              example: danger
              description: 'Bootstrap color token: success / warning / danger / info
                / primary / dark / secondary'
        summary:
          type: string
          description: Headline — incident title or truncated observation description
        category:
          type: string
          nullable: true
          description: incident_type for incidents, observation category for observations
        severity:
          type: object
          description: Severity (incidents) or observation_type (observations) with
            display label and color
          properties:
            value:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
            color:
              type: string
              description: Bootstrap color token
        location:
          type: object
          nullable: true
          properties:
            name:
              type: string
        date:
          type: string
          format: date-time
          description: |
            Event date — `occurred_at` (incidents) or `observed_at`
            (observations). Note: this is NOT the sort key. The feed is
            ordered by `submitted_at` desc.
        submitted_at:
          type: string
          format: date-time
          description: |
            When the row was filed (record `created_at`). This is the sort
            key for the feed (descending — most recent submissions first).
        status:
          type: object
          description: |
            Status with display label + color. For observations needing follow-up,
            the value is the synthetic `follow_up_required` (color `warning`)
            instead of the raw enum value — matching the desktop badge override.
          properties:
            value:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
            color:
              type: string
        anonymous:
          type: boolean
          description: |
            Whether the row was filed anonymously. Always `false` in the
            personal feed (anonymous rows are filtered upstream). May be
            `true` in the team feed (`team=true`), which includes anonymous
            rows so managers can triage them.
        submitter:
          type: object
          nullable: true
          description: |
            The user who filed the row — `reporter` for incidents,
            `observer` for observations. `null` when `anonymous: true`
            so the team feed doesn't leak the identity of anonymous
            reporters. `null` is also possible if the underlying user
            record has been removed.
          properties:
            id:
              type: integer
            name:
              type: string
              description: User's full name
        investigator:
          type: object
          nullable: true
          description: |
            Assigned investigator for incident rows
            (`Incident.assigned_investigator`). Always `null` for
            observation rows — observations have no investigator
            concept. Also `null` for incidents that have not yet had
            an investigator assigned.
          properties:
            id:
              type: integer
            name:
              type: string
              description: User's full name
        due_date:
          type: string
          format: date-time
          nullable: true
          description: |
            Investigation due date for incident rows
            (`Incident.investigation_due_at`). Always `null` for
            observation rows — observations have no due-date concept.
            Also `null` for incidents whose investigation has not
            been scheduled with a deadline.
        url:
          type: string
          description: Deep-link to the desktop record (e.g., `/apps/safety-hub/incidents/123`)
    SafetyHubSubmissionsSummary:
      type: object
      description: |
        Headline tile counts shown above the submissions feed. Scope follows
        the `team` query parameter so the tiles always match the rows in the
        feed:
          * `team` absent or `false` — the **current user's** own submissions
            this calendar month, with anonymous rows excluded.
          * `team=true` — **every submission in the business** this calendar
            month, including anonymous rows (manager-gated, same gate as the
            feed itself).
        Disabled per-module toggles (`incidents_enabled`,
        `observations_enabled`) contribute `0` in both scopes.
      properties:
        this_month:
          type: object
          description: Total submissions (incidents + observations) this calendar
            month, scoped per the `team` parameter
          properties:
            label:
              type: string
              example: This Month
            count:
              type: integer
              example: 6
            color:
              type: string
              example: info
              description: 'Bootstrap color token: success / warning / danger / info
                / primary / dark / secondary'
            icon:
              type: string
              example: calendar
              description: Stable icon-name token (client maps to its icon set)
        incidents:
          type: object
          description: Incident submissions this calendar month, scoped per the `team`
            parameter
          properties:
            label:
              type: string
              example: Incidents
            count:
              type: integer
              example: 3
            color:
              type: string
              example: danger
            icon:
              type: string
              example: alert-triangle
        observations:
          type: object
          description: Observation submissions this calendar month, scoped per the
            `team` parameter
          properties:
            label:
              type: string
              example: Observations
            count:
              type: integer
              example: 3
            color:
              type: string
              example: primary
            icon:
              type: string
              example: eye
    Holiday:
      type: object
      description: Business holiday information
      properties:
        id:
          type: integer
          description: Unique holiday ID
          example: 1
        name:
          type: string
          description: Holiday name
          example: New Year's Day
        date:
          type: string
          format: date
          description: Holiday date
          example: '2024-01-01'
        description:
          type: string
          nullable: true
          description: Holiday description
          example: New Year's Day celebration
        recurring:
          type: boolean
          description: Whether this holiday recurs annually
          example: true
        business_id:
          type: integer
          description: Business ID
          example: 1
        created_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
      required:
      - id
      - name
      - date
      - business_id
      - created_at
      - updated_at
    HolidayDetailed:
      allOf:
      - "$ref": "#/components/schemas/Holiday"
      - type: object
        properties:
          day_of_week:
            type: string
            description: Day of the week
            example: Monday
          week_of_year:
            type: integer
            description: Week number of the year
            example: 1
          month_name:
            type: string
            description: Month name
            example: January
          is_weekend:
            type: boolean
            description: Whether the holiday falls on a weekend
            example: false
          days_until:
            type: integer
            nullable: true
            description: Days until this holiday (null if past)
            example: 30
          metadata:
            type: object
            description: Additional holiday metadata
            properties:
              year:
                type: integer
                example: 2024
              month:
                type: integer
                example: 1
              day:
                type: integer
                example: 1
              quarter:
                type: integer
                example: 1
              is_past:
                type: boolean
                example: false
              is_today:
                type: boolean
                example: false
              is_future:
                type: boolean
                example: true
    BlackoutPeriod:
      type: object
      description: Leave blackout period information
      properties:
        id:
          type: integer
          description: Unique blackout period ID
          example: 1
        name:
          type: string
          description: Blackout period name
          example: Winter Holiday Season
        description:
          type: string
          nullable: true
          description: Blackout period description
          example: No leave allowed during winter holidays
        start_date:
          type: string
          format: date
          description: Start date of blackout period
          example: '2024-12-15'
        end_date:
          type: string
          format: date
          description: End date of blackout period
          example: '2024-12-31'
        block_requests:
          type: boolean
          description: Whether requests are completely blocked or require special
            approval
          example: true
        status:
          type: string
          description: Current status of the blackout period
          example: upcoming
        duration_days:
          type: integer
          description: Duration in days
          example: 17
        created_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
      required:
      - id
      - name
      - start_date
      - end_date
      - block_requests
      - status
      - duration_days
    BlackoutPeriodDetailed:
      allOf:
      - "$ref": "#/components/schemas/BlackoutPeriod"
      - type: object
        properties:
          leave_types:
            type: array
            description: Leave types affected by this blackout
            items:
              "$ref": "#/components/schemas/LeaveTypeBasic"
          locations:
            type: array
            description: Locations affected by this blackout
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                name:
                  type: string
                  example: Main Office
                address:
                  type: string
                  example: 123 Main St
          applies_to_all_leave_types:
            type: boolean
            description: Whether this applies to all leave types
            example: true
          applies_to_all_locations:
            type: boolean
            description: Whether this applies to all locations
            example: true
          days_until_start:
            type: integer
            nullable: true
            description: Days until blackout starts (null if past)
            example: 30
          days_until_end:
            type: integer
            nullable: true
            description: Days until blackout ends (null if past)
            example: 47
          is_current:
            type: boolean
            description: Whether this blackout is currently active
            example: false
          is_upcoming:
            type: boolean
            description: Whether this blackout is upcoming
            example: true
          is_past:
            type: boolean
            description: Whether this blackout is in the past
            example: false
          created_by:
            type: object
            nullable: true
            description: User who created this blackout period
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Admin User
          impact_message:
            type: string
            description: Message describing the impact of this blackout
            example: Leave requests are completely blocked during this period
    LeaveCoverageAvailability:
      type: object
      description: Leave availability check result
      properties:
        date:
          type: string
          format: date
          description: Date checked
          example: '2024-03-15'
        available:
          type: boolean
          description: Whether leave is available
          example: true
        unlimited:
          type: boolean
          description: Whether coverage is unlimited
          example: false
        current_off:
          type: integer
          description: Number of people currently off
          example: 2
        max_allowed:
          type: integer
          nullable: true
          description: Maximum people allowed off (null if unlimited)
          example: 5
        available_spots:
          type: integer
          nullable: true
          description: Available spots remaining (null if unlimited)
          example: 3
        limit_type:
          type: string
          description: Type of coverage limit
          example: percentage
        message:
          type: string
          description: Human-readable availability message
          example: 3 spots available out of 5 maximum
        metadata:
          type: object
          description: Additional date metadata
          properties:
            day_of_week:
              type: string
              example: Friday
            is_weekend:
              type: boolean
              example: false
            is_holiday:
              type: boolean
              example: false
            is_blackout_period:
              type: boolean
              example: false
      required:
      - date
      - available
      - unlimited
      - current_off
      - message
      - metadata
    LeaveCoverageDateRange:
      type: object
      description: Leave coverage for date range
      properties:
        start_date:
          type: string
          format: date
          example: '2024-03-15'
        end_date:
          type: string
          format: date
          example: '2024-03-17'
        total_days:
          type: integer
          description: Total days in range
          example: 3
        coverage_data:
          type: array
          description: Coverage data for each day
          items:
            allOf:
            - "$ref": "#/components/schemas/LeaveCoverageAvailability"
            - type: object
              properties:
                week_of_year:
                  type: integer
                  example: 11
                month:
                  type: integer
                  example: 3
        summary:
          type: object
          description: Summary statistics for the range
          properties:
            total_days:
              type: integer
              example: 3
            available_days:
              type: integer
              example: 2
            blocked_days:
              type: integer
              example: 1
            weekend_days:
              type: integer
              example: 0
            holiday_days:
              type: integer
              example: 0
            blackout_days:
              type: integer
              example: 1
            availability_percentage:
              type: number
              example: 66.7
      required:
      - start_date
      - end_date
      - total_days
      - coverage_data
      - summary
    LeaveCoverageAlternatives:
      type: object
      description: Alternative date suggestions
      properties:
        original_request:
          type: object
          description: Original requested dates
          properties:
            start_date:
              type: string
              format: date
              example: '2024-03-15'
            end_date:
              type: string
              format: date
              example: '2024-03-17'
            duration_days:
              type: integer
              example: 3
        alternatives:
          type: array
          description: Alternative date ranges
          items:
            type: object
            properties:
              start_date:
                type: string
                format: date
                example: '2024-03-22'
              end_date:
                type: string
                format: date
                example: '2024-03-24'
              duration_days:
                type: integer
                example: 3
              days_from_original:
                type: integer
                description: Days difference from original start date
                example: 7
              has_weekends:
                type: boolean
                example: true
              has_holidays:
                type: boolean
                example: false
              business_days:
                type: integer
                example: 3
        total_alternatives:
          type: integer
          description: Number of alternatives found
          example: 5
        search_period:
          type: object
          description: Period searched for alternatives
          properties:
            start_date:
              type: string
              format: date
              example: '2024-03-15'
            end_date:
              type: string
              format: date
              example: '2024-05-14'
      required:
      - original_request
      - alternatives
      - total_alternatives
      - search_period
    LeaveCoverageCalendar:
      type: object
      description: Monthly calendar view of leave coverage
      properties:
        year:
          type: integer
          example: 2024
        month:
          type: integer
          example: 3
        month_name:
          type: string
          example: March
        days_in_month:
          type: integer
          example: 31
        calendar_data:
          type: array
          description: Data for each day of the month
          items:
            allOf:
            - "$ref": "#/components/schemas/LeaveCoverageAvailability"
            - type: object
              properties:
                day_of_month:
                  type: integer
                  example: 15
                day_of_week_short:
                  type: string
                  example: Fri
                is_today:
                  type: boolean
                  example: false
                holiday:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                      example: 1
                    name:
                      type: string
                      example: Good Friday
                    description:
                      type: string
                      example: Christian holiday
                blackout_period:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                      example: 1
                    name:
                      type: string
                      example: Spring Break
                    blocks_requests:
                      type: boolean
                      example: true
                week_of_month:
                  type: integer
                  example: 3
        summary:
          type: object
          description: Monthly summary statistics
          properties:
            total_days:
              type: integer
              example: 31
            available_days:
              type: integer
              example: 25
            blocked_days:
              type: integer
              example: 6
            weekend_days:
              type: integer
              example: 8
            holiday_days:
              type: integer
              example: 2
            blackout_days:
              type: integer
              example: 4
      required:
      - year
      - month
      - month_name
      - days_in_month
      - calendar_data
      - summary
    LeaveRequestHistory:
      allOf:
      - "$ref": "#/components/schemas/LeaveRequestDetailed"
      - type: object
        properties:
          status_history:
            type: array
            description: Complete status change history
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                from_status:
                  type: string
                  example: pending
                to_status:
                  type: string
                  example: approved
                changed_by:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                      example: 2
                    name:
                      type: string
                      example: Manager Name
                notes:
                  type: string
                  nullable: true
                  example: Approved for vacation
                changed_at:
                  type: string
                  format: date-time
                  example: '2024-02-16T14:30:00Z'
    LeaveAnalytics:
      type: object
      description: Comprehensive leave usage analytics
      properties:
        period:
          type: object
          description: Analytics period
          properties:
            start_date:
              type: string
              format: date
              example: '2024-01-01'
            end_date:
              type: string
              format: date
              example: '2024-12-31'
            total_days:
              type: integer
              example: 366
        summary:
          type: object
          description: Overall summary statistics
          properties:
            total_requests:
              type: integer
              example: 25
            total_leave_days:
              type: integer
              example: 75
            approved_requests:
              type: integer
              example: 20
            approved_days:
              type: integer
              example: 60
            pending_requests:
              type: integer
              example: 3
            denied_requests:
              type: integer
              example: 1
            cancelled_requests:
              type: integer
              example: 1
        by_leave_type:
          type: array
          description: Analytics grouped by leave type
          items:
            type: object
            properties:
              leave_type:
                "$ref": "#/components/schemas/LeaveTypeBasic"
              total_requests:
                type: integer
                example: 15
              total_days:
                type: integer
                example: 45
              approved_requests:
                type: integer
                example: 12
              approved_days:
                type: integer
                example: 36
              pending_requests:
                type: integer
                example: 2
              denied_requests:
                type: integer
                example: 1
              cancelled_requests:
                type: integer
                example: 0
        by_month:
          type: array
          description: Analytics grouped by month
          items:
            type: object
            properties:
              month:
                type: string
                example: 2024-03
              month_name:
                type: string
                example: March 2024
              total_requests:
                type: integer
                example: 5
              total_days:
                type: integer
                example: 15
              approved_days:
                type: integer
                example: 12
        by_status:
          type: array
          description: Analytics grouped by status
          items:
            type: object
            properties:
              status:
                type: string
                example: approved
              count:
                type: integer
                example: 20
              total_days:
                type: integer
                example: 60
              percentage:
                type: number
                example: 80.0
        usage_patterns:
          type: object
          description: Usage patterns and insights
          properties:
            most_used_leave_type:
              type: object
              nullable: true
              description: Leave type with most usage
            busiest_month:
              type: object
              nullable: true
              description: Month with most leave days
            average_request_length:
              type: number
              description: Average length of leave requests in days
              example: 3.0
            longest_request:
              type: object
              nullable: true
              description: Longest leave request
            approval_rate:
              type: number
              description: Percentage of requests approved
              example: 80.0
      required:
      - period
      - summary
      - by_leave_type
      - by_month
      - by_status
      - usage_patterns
    BulkLeaveRequestResponse:
      type: object
      description: Response from bulk leave request operation
      properties:
        results:
          type: array
          description: Successful requests
          items:
            type: object
            properties:
              index:
                type: integer
                description: Index of request in original array
                example: 0
              success:
                type: boolean
                example: true
              leave_request:
                "$ref": "#/components/schemas/LeaveRequestDetailed"
        errors:
          type: array
          description: Failed requests
          items:
            type: object
            properties:
              index:
                type: integer
                description: Index of request in original array
                example: 1
              success:
                type: boolean
                example: false
              errors:
                type: array
                items:
                  type: string
                example:
                - Start date cannot be in the past
        summary:
          type: object
          description: Summary of bulk operation
          properties:
            total_requests:
              type: integer
              example: 5
            successful:
              type: integer
              example: 4
            failed:
              type: integer
              example: 1
      required:
      - results
      - errors
      - summary
    Timesheet:
      type: object
      properties:
        id:
          type: integer
          example: 123
        start_date:
          type: string
          format: date
          example: '2024-01-15'
        end_date:
          type: string
          format: date
          example: '2024-01-21'
        status:
          type: string
          enum:
          - pending
          - submitted
          - regional_pending
          - approved
          - rejected
          example: pending
        total_hours:
          type: number
          format: float
          example: 40.0
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          example: 0.0
        submission_date:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-22T09:00:00Z'
        approval_date:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-22T14:30:00Z'
        approved_by:
          type: string
          nullable: true
          example: John Manager
        regional_approved:
          type: boolean
          example: false
        regional_approved_by:
          type: string
          nullable: true
          example: Jane Regional
        regional_approved_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-22T16:00:00Z'
        editable:
          type: boolean
          description: Whether the timesheet can be edited
          example: true
        submittable:
          type: boolean
          description: Whether the timesheet can be submitted
          example: true
        entries_count:
          type: integer
          description: Number of entries in this timesheet
          example: 5
        missing_punches_count:
          type: integer
          description: Number of entries with missing punches
          example: 0
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-21T18:00:00Z'
    TimesheetDetailed:
      allOf:
      - "$ref": "#/components/schemas/Timesheet"
      - type: object
        properties:
          entries:
            type: array
            items:
              "$ref": "#/components/schemas/TimesheetEntry"
          entries_by_date:
            type: object
            description: Entries grouped by date
            additionalProperties:
              type: array
              items:
                "$ref": "#/components/schemas/TimesheetEntry"
          summary:
            "$ref": "#/components/schemas/TimesheetSummary"
    TimesheetEntry:
      type: object
      properties:
        id:
          type: integer
          example: 456
        date:
          type: string
          format: date
          example: '2024-01-15'
        start_time:
          type: string
          format: time
          nullable: true
          example: '09:00:00'
        end_time:
          type: string
          format: time
          nullable: true
          example: '17:00:00'
        hours:
          type: number
          format: float
          nullable: true
          example: 8.0
        edited:
          type: boolean
          description: Whether this entry has been manually edited
          example: false
        edit_notes:
          type: string
          nullable: true
          description: Notes about edits made to this entry
          example: Corrected clock-in time
        missing_punch:
          type: boolean
          description: Whether this entry is missing clock-in or clock-out
          example: false
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
        attendance_record_id:
          type: integer
          nullable: true
          description: Associated attendance record ID
          example: 789
        shift_id:
          type: integer
          nullable: true
          description: Associated shift ID
          example: 101
        shift:
          type: object
          nullable: true
          description: Associated shift information
          properties:
            id:
              type: integer
              example: 101
            title:
              type: string
              example: Morning Shift
            location:
              type: string
              nullable: true
              example: Main Office
            is_ad_hoc:
              type: boolean
              example: false
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T09:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T17:00:00Z'
    TimesheetEntryDetailed:
      allOf:
      - "$ref": "#/components/schemas/TimesheetEntry"
      - type: object
        properties:
          timesheet:
            type: object
            description: Associated timesheet information
            properties:
              id:
                type: integer
                example: 123
              start_date:
                type: string
                format: date
                example: '2024-01-15'
              end_date:
                type: string
                format: date
                example: '2024-01-21'
              status:
                type: string
                example: pending
          edit_history:
            type: array
            description: History of edits made to this entry
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                field_changed:
                  type: string
                  example: start_time
                original_value:
                  type: string
                  example: '08:45:00'
                new_value:
                  type: string
                  example: '09:00:00'
                reason:
                  type: string
                  example: Employee correction
                edited_by:
                  type: string
                  example: John Employee
                edited_at:
                  type: string
                  format: date-time
                  example: '2024-01-15T10:00:00Z'
          attendance_record:
            type: object
            nullable: true
            description: Associated attendance record details
            properties:
              id:
                type: integer
                example: 789
              check_in_time:
                type: string
                format: date-time
                nullable: true
                example: '2024-01-15T09:00:00Z'
              check_out_time:
                type: string
                format: date-time
                nullable: true
                example: '2024-01-15T17:00:00Z'
              status:
                type: string
                example: completed
              requires_review:
                type: boolean
                example: false
    TimesheetSummary:
      type: object
      properties:
        total_hours:
          type: number
          format: float
          example: 40.0
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          example: 0.0
        total_entries:
          type: integer
          example: 5
        missing_punches:
          type: integer
          example: 0
        edited_entries:
          type: integer
          example: 1
        days_with_entries:
          type: integer
          example: 5
    PaySummary:
      type: object
      properties:
        pay_period:
          type: object
          properties:
            start_date:
              type: string
              format: date
              example: '2024-01-15'
            end_date:
              type: string
              format: date
              example: '2024-01-21'
            period_type:
              type: string
              enum:
              - weekly
              - bi-weekly
              - semi-monthly
              - monthly
              example: weekly
        hours:
          type: object
          properties:
            regular_hours:
              type: number
              format: float
              example: 40.0
            overtime_hours:
              type: number
              format: float
              example: 2.5
            total_hours:
              type: number
              format: float
              example: 42.5
        estimated_pay:
          type: object
          properties:
            regular_pay:
              type: number
              format: float
              example: 1000.0
            overtime_pay:
              type: number
              format: float
              example: 93.75
            total_pay:
              type: number
              format: float
              example: 1093.75
            hourly_rate:
              type: number
              format: float
              example: 25.0
            overtime_rate:
              type: number
              format: float
              example: 37.5
        timesheet_status:
          type: string
          enum:
          - not_created
          - pending
          - submitted
          - approved
          - rejected
          example: pending
        last_updated:
          type: string
          format: date-time
          example: '2024-01-20T10:30:00Z'
    PayHistoryItem:
      type: object
      properties:
        type:
          type: string
          enum:
          - timesheet
          - paycheck
          example: timesheet
        id:
          type: integer
          example: 123
        period_start:
          type: string
          format: date
          example: '2024-01-15'
        period_end:
          type: string
          format: date
          example: '2024-01-21'
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          example: 2.5
        total_hours:
          type: number
          format: float
          example: 42.5
        status:
          type: string
          description: Status (for timesheets)
          example: approved
        approved_at:
          type: string
          format: date-time
          description: Approval date (for timesheets)
          example: '2024-01-22T14:30:00Z'
        estimated_pay:
          type: number
          format: float
          description: Estimated pay (for timesheets)
          example: 1093.75
        gross_pay:
          type: number
          format: float
          description: Gross pay (for paychecks)
          example: 1093.75
        net_pay:
          type: number
          format: float
          description: Net pay (for paychecks)
          example: 850.0
        pay_date:
          type: string
          format: date
          description: Pay date (for paychecks)
          example: '2024-01-26'
    PayPeriodDetailed:
      type: object
      properties:
        id:
          type: string
          example: 2024-01-15_2024-01-21
        start_date:
          type: string
          format: date
          example: '2024-01-15'
        end_date:
          type: string
          format: date
          example: '2024-01-21'
        period_type:
          type: string
          enum:
          - weekly
          - bi-weekly
          - semi-monthly
          - monthly
          example: weekly
        days_in_period:
          type: integer
          example: 7
        is_current:
          type: boolean
          example: true
        is_future:
          type: boolean
          example: false
        is_past:
          type: boolean
          example: false
        payday:
          type: string
          format: date
          example: '2024-01-26'
        days_until_payday:
          type: integer
          description: Days until payday (for current period)
          example: 5
        days_remaining:
          type: integer
          description: Days remaining in period (for current period)
          example: 2
        timesheet:
          type: object
          properties:
            id:
              type: integer
              example: 123
            status:
              type: string
              enum:
              - pending
              - submitted
              - approved
              - rejected
              example: pending
            total_hours:
              type: number
              format: float
              example: 42.5
            regular_hours:
              type: number
              format: float
              example: 40.0
            overtime_hours:
              type: number
              format: float
              example: 2.5
            submission_date:
              type: string
              format: date-time
              example: '2024-01-21T17:00:00Z'
            approval_date:
              type: string
              format: date-time
              example: '2024-01-22T09:00:00Z'
            editable:
              type: boolean
              example: true
            submittable:
              type: boolean
              example: true
        estimated_pay:
          type: number
          format: float
          example: 1093.75
        work_days:
          type: integer
          example: 5
    NextPayday:
      type: object
      properties:
        date:
          type: string
          format: date
          example: '2024-01-26'
        days_until:
          type: integer
          example: 5
        period_start:
          type: string
          format: date
          example: '2024-01-15'
        period_end:
          type: string
          format: date
          example: '2024-01-21'
    YTDSummary:
      type: object
      properties:
        year:
          type: integer
          example: 2024
        period:
          type: object
          properties:
            start_date:
              type: string
              format: date
              example: '2024-01-01'
            end_date:
              type: string
              format: date
              example: '2024-12-31'
            days_elapsed:
              type: integer
              example: 20
            days_remaining:
              type: integer
              example: 345
        hours:
          type: object
          properties:
            regular_hours:
              type: number
              format: float
              example: 160.0
            overtime_hours:
              type: number
              format: float
              example: 10.0
            total_hours:
              type: number
              format: float
              example: 170.0
            average_weekly_hours:
              type: number
              format: float
              example: 42.5
        estimated_pay:
          type: object
          properties:
            regular_pay:
              type: number
              format: float
              example: 4000.0
            overtime_pay:
              type: number
              format: float
              example: 375.0
            total_pay:
              type: number
              format: float
              example: 4375.0
        actual_pay:
          type: object
          properties:
            gross_pay:
              type: number
              format: float
              example: 4200.0
            net_pay:
              type: number
              format: float
              example: 3200.0
            paychecks_count:
              type: integer
              example: 4
        timesheets:
          type: object
          properties:
            total_count:
              type: integer
              example: 4
            approved_count:
              type: integer
              example: 3
    Paycheck:
      type: object
      properties:
        id:
          type: integer
          example: 123
        pay_date:
          type: string
          format: date
          example: '2024-01-26'
        pay_period_start:
          type: string
          format: date
          example: '2024-01-15'
        pay_period_end:
          type: string
          format: date
          example: '2024-01-21'
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          example: 2.5
        total_hours:
          type: number
          format: float
          example: 42.5
        gross_pay:
          type: number
          format: float
          example: 1093.75
        net_pay:
          type: number
          format: float
          example: 850.0
        pay_frequency:
          type: string
          enum:
          - weekly
          - bi-weekly
          - semi-monthly
          - monthly
          example: weekly
        has_file:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2024-01-26T08:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-26T08:00:00Z'
    PaycheckDetailed:
      allOf:
      - "$ref": "#/components/schemas/Paycheck"
      - type: object
        properties:
          earnings:
            type: object
            properties:
              regular_pay:
                type: number
                format: float
                example: 1000.0
              overtime_pay:
                type: number
                format: float
                example: 93.75
              holiday_pay:
                type: number
                format: float
                example: 0.0
              sick_pay:
                type: number
                format: float
                example: 0.0
              vacation_pay:
                type: number
                format: float
                example: 0.0
              bonus:
                type: number
                format: float
                example: 0.0
              commission:
                type: number
                format: float
                example: 0.0
              other_earnings:
                type: number
                format: float
                example: 0.0
              gross_pay:
                type: number
                format: float
                example: 1093.75
          deductions:
            type: object
            properties:
              health_insurance:
                type: number
                format: float
                example: 125.0
              dental_insurance:
                type: number
                format: float
                example: 15.0
              vision_insurance:
                type: number
                format: float
                example: 5.0
              life_insurance:
                type: number
                format: float
                example: 10.0
              retirement_401k:
                type: number
                format: float
                example: 50.0
              retirement_roth:
                type: number
                format: float
                example: 0.0
              hsa:
                type: number
                format: float
                example: 25.0
              fsa:
                type: number
                format: float
                example: 0.0
              parking:
                type: number
                format: float
                example: 20.0
              union_dues:
                type: number
                format: float
                example: 0.0
              other_deductions:
                type: number
                format: float
                example: 0.0
              total_deductions:
                type: number
                format: float
                example: 250.0
          taxes:
            type: object
            properties:
              federal_income_tax:
                type: number
                format: float
                example: 150.0
              state_income_tax:
                type: number
                format: float
                example: 50.0
              local_income_tax:
                type: number
                format: float
                example: 10.0
              social_security:
                type: number
                format: float
                example: 67.81
              medicare:
                type: number
                format: float
                example: 15.86
              unemployment_tax:
                type: number
                format: float
                example: 0.0
              disability_tax:
                type: number
                format: float
                example: 5.0
              other_taxes:
                type: number
                format: float
                example: 0.0
              total_taxes:
                type: number
                format: float
                example: 298.67
          employer_contributions:
            type: object
            properties:
              health_insurance:
                type: number
                format: float
                example: 200.0
              retirement_match:
                type: number
                format: float
                example: 25.0
              social_security:
                type: number
                format: float
                example: 67.81
              medicare:
                type: number
                format: float
                example: 15.86
              unemployment:
                type: number
                format: float
                example: 6.56
              workers_comp:
                type: number
                format: float
                example: 10.94
              other_contributions:
                type: number
                format: float
                example: 0.0
              total_contributions:
                type: number
                format: float
                example: 326.17
          year_to_date:
            type: object
            properties:
              gross_pay:
                type: number
                format: float
                example: 4375.0
              net_pay:
                type: number
                format: float
                example: 3400.0
              regular_hours:
                type: number
                format: float
                example: 160.0
              overtime_hours:
                type: number
                format: float
                example: 10.0
              total_hours:
                type: number
                format: float
                example: 170.0
              federal_tax:
                type: number
                format: float
                example: 600.0
              state_tax:
                type: number
                format: float
                example: 200.0
              social_security:
                type: number
                format: float
                example: 271.25
              medicare:
                type: number
                format: float
                example: 63.44
          connection:
            type: object
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: ADP Payroll Connection
              provider:
                type: string
                example: adp
    PayPeriod:
      type: object
      properties:
        id:
          type: string
          example: 2024-01-15_2024-01-21
        start_date:
          type: string
          format: date
          example: '2024-01-15'
        end_date:
          type: string
          format: date
          example: '2024-01-21'
        period_type:
          type: string
          enum:
          - weekly
          - bi-weekly
          - semi-monthly
          - monthly
          example: weekly
        days_in_period:
          type: integer
          example: 7
        is_current:
          type: boolean
          example: true
        is_future:
          type: boolean
          example: false
        is_past:
          type: boolean
          example: false
    PayPeriodSummary:
      type: object
      properties:
        period:
          "$ref": "#/components/schemas/PayPeriod"
        hours:
          type: object
          properties:
            timesheet:
              type: object
              properties:
                regular:
                  type: number
                  format: float
                  example: 40.0
                overtime:
                  type: number
                  format: float
                  example: 2.5
                total:
                  type: number
                  format: float
                  example: 42.5
            attendance:
              type: object
              properties:
                regular:
                  type: number
                  format: float
                  example: 39.5
                overtime:
                  type: number
                  format: float
                  example: 2.0
                total:
                  type: number
                  format: float
                  example: 41.5
            scheduled:
              type: number
              format: float
              example: 40.0
            variance:
              type: object
              properties:
                timesheet_vs_scheduled:
                  type: number
                  format: float
                  example: 2.5
                attendance_vs_scheduled:
                  type: number
                  format: float
                  example: 1.5
                timesheet_vs_attendance:
                  type: number
                  format: float
                  example: 1.0
        attendance:
          type: object
          properties:
            total_records:
              type: integer
              example: 5
            completed_shifts:
              type: integer
              example: 5
            missed_shifts:
              type: integer
              example: 0
            late_arrivals:
              type: integer
              example: 1
            early_departures:
              type: integer
              example: 0
        estimated_pay:
          type: number
          format: float
          example: 1093.75
        timesheet_status:
          type: string
          enum:
          - not_created
          - pending
          - submitted
          - approved
          - rejected
          example: pending
        completion_percentage:
          type: number
          format: float
          example: 85.7
    ManualTimeEntry:
      type: object
      properties:
        id:
          type: integer
          example: 456
        timesheet_id:
          type: integer
          example: 123
        date:
          type: string
          format: date
          example: '2024-01-15'
        start_time:
          type: string
          format: time
          example: '09:00:00'
        end_time:
          type: string
          format: time
          example: '17:00:00'
        hours:
          type: number
          format: float
          example: 8.0
        entry_type:
          type: string
          enum:
          - missing_punch
          - regular
          - overtime
          example: missing_punch
        edit_notes:
          type: string
          example: Manual entry for missed punch
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
        shift:
          type: object
          properties:
            id:
              type: integer
              example: 789
            title:
              type: string
              example: Morning Shift
            location:
              type: string
              example: Main Office
        timesheet:
          type: object
          properties:
            id:
              type: integer
              example: 123
            start_date:
              type: string
              format: date
              example: '2024-01-15'
            end_date:
              type: string
              format: date
              example: '2024-01-21'
            status:
              type: string
              enum:
              - pending
              - submitted
              - approved
              - rejected
              example: pending
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T18:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T18:00:00Z'
    ManualTimeEntryDetailed:
      allOf:
      - "$ref": "#/components/schemas/ManualTimeEntry"
      - type: object
        properties:
          edit_history:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                field_changed:
                  type: string
                  example: end_time
                original_value:
                  type: string
                  example: '16:30:00'
                new_value:
                  type: string
                  example: '17:00:00'
                reason:
                  type: string
                  example: Employee requested time correction
                edited_by:
                  type: string
                  example: John Doe
                edited_at:
                  type: string
                  format: date-time
                  example: '2024-01-16T09:00:00Z'
          validation_warnings:
            type: array
            items:
              type: object
              properties:
                type:
                  type: string
                  enum:
                  - overlap
                  - excessive_hours
                  - weekend_work
                  example: overlap
                message:
                  type: string
                  example: This entry overlaps with another time entry on the same
                    date
          pay_calculation:
            type: object
            properties:
              regular_hours:
                type: number
                format: float
                example: 8.0
              overtime_hours:
                type: number
                format: float
                example: 0.0
              regular_pay:
                type: number
                format: float
                example: 200.0
              overtime_pay:
                type: number
                format: float
                example: 0.0
              total_pay:
                type: number
                format: float
                example: 200.0
              hourly_rate:
                type: number
                format: float
                example: 25.0
              overtime_rate:
                type: number
                format: float
                example: 37.5
    Skill:
      type: object
      properties:
        id:
          type: integer
          example: 123
        name:
          type: string
          example: JavaScript Development
        description:
          type: string
          nullable: true
          example: Frontend and backend JavaScript programming
        category:
          type: string
          nullable: true
          example: technical_skills
        category_display:
          type: string
          nullable: true
          example: Technical Skills
        requires_certification:
          type: boolean
          example: true
        skill_kind:
          type: string
          enum:
          - skill
          - certification
          - license
          description: 'Catalog credential type. Clients map `license` -> LIC badge,
            `certification` -> CERT badge, `skill` -> no badge.

            '
          example: certification
        requires_document_upload:
          type: boolean
          example: false
        active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
    SkillDetailed:
      allOf:
      - "$ref": "#/components/schemas/Skill"
      - type: object
        properties:
          metadata:
            type: object
            nullable: true
            description: Additional skill metadata
          help_desk_tier:
            type: integer
            nullable: true
            example: 2
          employee_count:
            type: integer
            example: 15
          average_proficiency:
            type: number
            format: float
            nullable: true
            example: 3.4
    EmployeeSkill:
      type: object
      properties:
        id:
          type: integer
          example: 456
        skill_id:
          type: integer
          example: 123
        proficiency_level:
          type: integer
          minimum: 1
          maximum: 5
          example: 3
        certification_date:
          type: string
          format: date
          nullable: true
          example: '2024-01-15'
        expiration_date:
          type: string
          format: date
          nullable: true
          example: '2025-01-15'
        certification_number:
          type: string
          nullable: true
          example: CERT-2024-001
        notes:
          type: string
          nullable: true
          example: Completed advanced course
        verified:
          type: boolean
          example: true
        verified_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-16T10:00:00Z'
        active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        skill:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: JavaScript Development
            category:
              type: string
              nullable: true
              example: technical_skills
            category_display:
              type: string
              nullable: true
              example: Technical Skills
            requires_certification:
              type: boolean
              example: true
            skill_kind:
              type: string
              enum:
              - skill
              - certification
              - license
              description: Raw catalog credential type. Drives the LIC/CERT badge
                in the web/mobile UI — clients map license -> "LIC", certification
                -> "CERT", skill -> no badge.
              example: certification
    EmployeeSkillDetailed:
      allOf:
      - "$ref": "#/components/schemas/EmployeeSkill"
      - type: object
        properties:
          verifier:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 789
              name:
                type: string
                example: John Manager
          days_until_expiration:
            type: integer
            nullable: true
            example: 45
          expired:
            type: boolean
            example: false
          expiring_soon:
            type: boolean
            example: true
          proficiency_text:
            type: string
            example: Intermediate
          verification_status:
            type: string
            enum:
            - verified
            - pending
            - unverified
            example: verified
    Certification:
      type: object
      properties:
        id:
          type: integer
          example: 456
        skill_id:
          type: integer
          example: 123
        certification_date:
          type: string
          format: date
          example: '2024-01-15'
        expiration_date:
          type: string
          format: date
          nullable: true
          example: '2025-01-15'
        certification_number:
          type: string
          nullable: true
          example: CERT-2024-001
        verified:
          type: boolean
          example: true
        verified_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-16T10:00:00Z'
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        skill:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: JavaScript Development
            category:
              type: string
              nullable: true
              example: technical_skills
            category_display:
              type: string
              nullable: true
              example: Technical Skills
    CertificationDetailed:
      allOf:
      - "$ref": "#/components/schemas/Certification"
      - type: object
        properties:
          notes:
            type: string
            nullable: true
            example: Completed advanced course
          proficiency_level:
            type: integer
            minimum: 1
            maximum: 5
            example: 3
          verifier:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 789
              name:
                type: string
                example: John Manager
          days_until_expiration:
            type: integer
            nullable: true
            example: 45
          expired:
            type: boolean
            example: false
          expiring_soon:
            type: boolean
            example: true
          status:
            type: string
            enum:
            - active
            - expiring_soon
            - expiring_notice
            - expired
            example: active
          document_attached:
            type: boolean
            example: true
    LoginConfiguration:
      type: object
      properties:
        business:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: Office Chat Solutions
            subdomain:
              type: string
              example: officechat
            logo_url:
              type: string
              nullable: true
              example: https://cdn.workforce.mangoapps.com/logos/officechat.png
            timezone:
              type: string
              example: America/New_York
            branding:
              "$ref": "#/components/schemas/Business/properties/branding"
        login_methods:
          type: object
          properties:
            email_password:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                forgot_password_enabled:
                  type: boolean
                  example: true
            sso_providers:
              type: array
              description: |
                Array of configured SSO providers for this business. Multiple providers of the same
                type (e.g., multiple Google OAuth2 configurations) are supported. Each provider has
                a unique ID that should be passed to the SSO initiate endpoint.
              items:
                type: object
                properties:
                  id:
                    type: string
                    description: Unique identifier for this SSO configuration (use
                      as provider_id)
                    example: '67'
                  provider_type:
                    type: string
                    enum:
                    - google_oauth2
                    - saml
                    - entra_id
                    - mangoapps
                    description: The underlying technology/provider type
                    example: google_oauth2
                  provider_name:
                    type: string
                    description: User-friendly name for this SSO provider
                    example: Google Workspace - Partners
                  enabled:
                    type: boolean
                    description: Whether this SSO configuration is currently active
                    example: true
                  logo_url:
                    type: string
                    nullable: true
                    description: URL to the provider's logo for display in UI
                    example: https://cdn.workforce.mangoapps.com/logos/google.png
                  description:
                    type: string
                    description: A short description for the SSO provider
                    example: Sign in with your Google Workspace account
                  channel_support:
                    type: string
                    enum:
                    - web_only
                    - mobile_only
                    - both
                    description: Indicates which channels (web, mobile, or both) this
                      SSO configuration supports
                    example: both
                  mobile_supported:
                    type: boolean
                    description: Derived flag indicating if this SSO configuration
                      is available for mobile apps
                    example: true
                  web_supported:
                    type: boolean
                    description: Derived flag indicating if this SSO configuration
                      is available for web browsers
                    example: true
        two_factor_authentication:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            methods:
              type: array
              items:
                type: string
                enum:
                - sms
                - email
                - authenticator_app
                - backup_codes
              example:
              - sms
              - authenticator_app
            backup_codes:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                count:
                  type: integer
                  example: 8
        passwordless_authentication:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            methods:
              type: array
              items:
                type: string
                enum:
                - magic_link
                - sms_code
                - email_code
              example:
              - magic_link
              - email_code
        biometric_authentication:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            supported_methods:
              type: array
              items:
                type: string
                enum:
                - face_id
                - touch_id
                - fingerprint
              example:
              - face_id
              - touch_id
              - fingerprint
        remember_device:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            duration_days:
              type: integer
              example: 30
        api_endpoints:
          type: object
          properties:
            login:
              type: string
              example: "/api/v1/auth/login"
            sso_initiate:
              type: string
              example: "/api/v1/auth/sso/initiate"
            sso_exchange:
              type: string
              example: "/api/v1/auth/sso/exchange"
            refresh_token:
              type: string
              example: "/api/v1/auth/refresh"
            logout:
              type: string
              example: "/api/v1/auth/logout"
            passwordless_request:
              type: string
              example: "/api/v1/auth/passwordless/request"
            passwordless_verify:
              type: string
              example: "/api/v1/auth/passwordless/verify"
    SsoInitiateResponse:
      type: object
      properties:
        authorization_url:
          type: string
          description: URL to open for SSO authentication
          example: https://accounts.google.com/o/oauth2/v2/auth?client_id=...
        state:
          type: string
          description: State parameter for security verification
          example: abc123def456
        app_redirect_uri:
          type: string
          nullable: true
          description: |
            Echo of the accepted `app_redirect_uri`, or null when the submitted value was
            dropped by the allowlist. Desktop clients MUST compare this against what they
            sent: a value that does not come back will never be reached by the callback
            page, so fall back to a custom scheme BEFORE opening the browser rather than
            waiting out a sign-in that cannot complete. Servers predating this field omit
            it entirely, which clients must also read as "not accepted".
          example: http://127.0.0.1:52341/sso-callback
        provider:
          type: object
          properties:
            type:
              type: string
              example: google_oauth2
            name:
              type: string
              example: Google Workspace
        expires_in:
          type: integer
          description: State expiration time in seconds
          example: 600
        instructions:
          type: object
          properties:
            message:
              type: string
              example: Open the authorization URL in a web view or external browser
            next_step:
              type: string
              example: After authorization, exchange the received code using the /exchange
                endpoint
    SsoExchangeResponse:
      type: object
      properties:
        access_token:
          type: string
          description: Access token for API requests
          example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
        refresh_token:
          type: string
          description: Refresh token for token renewal
          example: def456ghi789...
        expires_in:
          type: integer
          description: Access token expiration time in seconds
          example: 3600
        token_type:
          type: string
          example: Bearer
        user:
          "$ref": "#/components/schemas/User"
        business:
          "$ref": "#/components/schemas/Business"
        sso_provider:
          type: object
          properties:
            type:
              type: string
              example: google_oauth2
            name:
              type: string
              example: Google Workspace
    EnhancedUser:
      type: object
      description: Enhanced user profile with additional security and metadata
      properties:
        id:
          type: integer
          example: 123
        email:
          type: string
          format: email
          example: john.doe@example.com
        first_name:
          type: string
          example: John
        last_name:
          type: string
          example: Doe
        full_name:
          type: string
          example: John Doe
        role:
          type: string
          example: Employee
        active:
          type: boolean
          example: true
        avatar_url:
          type: string
          nullable: true
          example: https://example.com/avatar.jpg
        phone:
          type: string
          nullable: true
          example: "+1234567890"
        time_zone:
          type: string
          nullable: true
          example: America/New_York
        employee_type:
          type: string
          nullable: true
          example: Full-Time
        hire_date:
          type: string
          format: date
          nullable: true
          example: '2023-01-15'
        last_activity_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-15T10:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2023-01-15T09:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:30:00Z'
    SecurityInfo:
      type: object
      description: User security information and status
      properties:
        two_factor_enabled:
          type: boolean
          description: Whether 2FA is enabled for the user
          example: true
        two_factor_status:
          type: string
          enum:
          - disabled
          - pending_setup
          - pending_confirmation
          - enabled
          description: Current 2FA setup status
          example: enabled
        account_locked:
          type: boolean
          description: Whether the account is currently locked
          example: false
        last_sign_in_at:
          type: string
          format: date-time
          nullable: true
          description: Last successful sign-in timestamp
          example: '2024-01-15T08:30:00Z'
        last_sign_in_ip:
          type: string
          nullable: true
          description: IP address of last sign-in
          example: 192.168.1.100
        sign_in_count:
          type: integer
          description: Total number of sign-ins
          example: 42
        failed_attempts:
          type: integer
          description: Number of failed login attempts
          example: 0
        locked_at:
          type: string
          format: date-time
          nullable: true
          description: When the account was locked
        password_changed_at:
          type: string
          format: date-time
          nullable: true
          description: When the password was last changed
          example: '2024-01-01T00:00:00Z'
    Session:
      type: object
      description: User session information
      properties:
        id:
          type: integer
          description: Session ID
          example: 456
        name:
          type: string
          description: Session name/description
          example: Mobile Access Token - 2024-01-15 10:30
        created_at:
          type: string
          format: date-time
          description: When the session was created
          example: '2024-01-15T10:30:00Z'
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: When the session was last used
          example: '2024-01-15T14:30:00Z'
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: When the session expires
          example: '2024-01-15T11:30:00Z'
        is_current:
          type: boolean
          description: Whether this is the current session
          example: true
        status:
          type: string
          enum:
          - active
          - idle
          - expired
          - revoked
          description: Current session status
          example: active
    DetailedSession:
      allOf:
      - "$ref": "#/components/schemas/Session"
      - type: object
        properties:
          scopes:
            type: array
            items:
              type: string
            description: Permission scopes for this session
            example:
            - read:shifts
            - write:shifts
            - read:users
          business_id:
            type: integer
            description: Associated business ID
            example: 789
          user_agent:
            type: object
            description: User agent information
            properties:
              browser:
                type: string
                example: Chrome
              platform:
                type: string
                example: macOS
              raw:
                type: string
                example: Mobile Access Token - 2024-01-15 10:30
          location_info:
            type: object
            description: Location information (if available)
            properties:
              ip_address:
                type: string
                nullable: true
                description: IP address (not exposed for security)
              country:
                type: string
                nullable: true
                example: United States
              city:
                type: string
                nullable: true
                example: New York
              estimated:
                type: boolean
                description: Whether location is estimated
                example: true
          device_info:
            type: object
            description: Device information
            properties:
              type:
                type: string
                enum:
                - mobile
                - tablet
                - desktop
                example: mobile
              name:
                type: string
                example: Mobile Access Token - 2024-01-15 10:30
              trusted:
                type: boolean
                description: Whether the device is considered trusted
                example: true
          security_info:
            type: object
            description: Security-related session information
            properties:
              service_account:
                type: boolean
                description: Whether this is a service account token
                example: false
              has_secret:
                type: boolean
                description: Whether the token has an associated secret
                example: false
              token_type:
                type: string
                enum:
                - user_session
                - service_account
                description: Type of authentication token
                example: user_session
    PasswordlessToken:
      type: object
      description: Passwordless authentication token information
      properties:
        id:
          type: integer
          description: Token ID
          example: 123
        type:
          type: string
          enum:
          - magic_link
          - verification_code
          - sms_code
          description: Type of passwordless token
          example: magic_link
        created_at:
          type: string
          format: date-time
          description: When the token was created
          example: '2024-01-15T10:30:00Z'
        expires_at:
          type: string
          format: date-time
          description: When the token expires
          example: '2024-01-15T11:30:00Z'
        verified_at:
          type: string
          format: date-time
          nullable: true
          description: When the token was verified
          example: '2024-01-15T10:35:00Z'
        expired:
          type: boolean
          description: Whether the token has expired
          example: false
        verified:
          type: boolean
          description: Whether the token has been verified
          example: true
        revoked:
          type: boolean
          description: Whether the token has been revoked
          example: false
    PersonalApiToken:
      type: object
      description: Personal API token information
      properties:
        id:
          type: integer
          description: Token ID
          example: 456
        name:
          type: string
          description: Token name/description
          example: My Automation Token
        created_at:
          type: string
          format: date-time
          description: When the token was created
          example: '2024-01-15T10:30:00Z'
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: When the token was last used
          example: '2024-01-15T14:30:00Z'
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: When the token expires
          example: '2024-12-31T23:59:59Z'
        expired:
          type: boolean
          description: Whether the token has expired
          example: false
        active:
          type: boolean
          description: Whether the token is active
          example: true
        scopes:
          type: array
          items:
            type: string
          description: Token scopes/permissions
          example:
          - read:own_profile
          - read:own_shifts
    DetailedPersonalApiToken:
      allOf:
      - "$ref": "#/components/schemas/PersonalApiToken"
      - type: object
        properties:
          token:
            type: string
            description: The actual token value (only shown on creation/rotation)
            example: pat_1234567890abcdef
          business_id:
            type: integer
            description: Associated business ID
            example: 789
          service_account:
            type: boolean
            description: Whether this is a service account token
            example: false
          scope_count:
            type: integer
            description: Number of scopes assigned
            example: 4
          days_until_expiration:
            type: integer
            nullable: true
            description: Days until expiration (null if no expiration)
            example: 365
          usage_summary:
            type: object
            description: Usage summary information
            properties:
              last_used:
                type: string
                description: Human-readable last used time
                example: 2 hours ago
              created:
                type: string
                description: Human-readable creation time
                example: 3 days ago
    TokenUsage:
      type: object
      description: Token usage statistics and analytics
      properties:
        token_id:
          type: integer
          description: Token ID
          example: 456
        token_name:
          type: string
          description: Token name
          example: My Automation Token
        created_at:
          type: string
          format: date-time
          description: Token creation date
          example: '2024-01-15T10:30:00Z'
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: Last usage timestamp
          example: '2024-01-15T14:30:00Z'
        total_requests:
          type: integer
          description: Total number of API requests
          example: 1250
        requests_last_24h:
          type: integer
          description: Requests in the last 24 hours
          example: 45
        requests_last_7d:
          type: integer
          description: Requests in the last 7 days
          example: 320
        requests_last_30d:
          type: integer
          description: Requests in the last 30 days
          example: 1100
        most_used_endpoints:
          type: array
          items:
            type: object
            properties:
              endpoint:
                type: string
                example: "/api/v1/shifts"
              method:
                type: string
                example: GET
              count:
                type: integer
                example: 150
          description: Most frequently used endpoints
        recent_activity:
          type: array
          items:
            type: object
            properties:
              timestamp:
                type: string
                format: date-time
                example: '2024-01-15T14:30:00Z'
              endpoint:
                type: string
                example: "/api/v1/auth/profile"
              method:
                type: string
                example: GET
              status_code:
                type: integer
                example: 200
              ip_address:
                type: string
                example: 192.168.1.100
          description: Recent API activity
    UserSecuritySettings:
      type: object
      description: User security settings and status
      properties:
        two_factor_auth:
          type: object
          properties:
            enabled:
              type: boolean
              description: Whether two-factor authentication is enabled
              example: true
            backup_codes_count:
              type: integer
              description: Number of remaining backup codes
              example: 8
            last_used:
              type: string
              format: date-time
              nullable: true
              description: When 2FA was last used
              example: '2024-01-15T10:30:00Z'
            setup_required:
              type: boolean
              description: Whether 2FA setup is required
              example: false
        trusted_devices:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                description: Device ID
                example: device_123
              name:
                type: string
                description: Device name
                example: iPhone 15 Pro
              last_seen:
                type: string
                format: date-time
                description: When device was last seen
                example: '2024-01-15T14:22:00Z'
              location:
                type: string
                nullable: true
                description: Device location
                example: New York, NY
              ip_address:
                type: string
                description: Device IP address
                example: 192.168.1.100
              is_current:
                type: boolean
                description: Whether this is the current device
                example: true
        security_questions:
          type: object
          properties:
            configured:
              type: boolean
              description: Whether security questions are configured
              example: true
            last_updated:
              type: string
              format: date-time
              nullable: true
              description: When security questions were last updated
              example: '2024-01-10T09:15:00Z'
        login_history:
          type: array
          items:
            type: object
            properties:
              timestamp:
                type: string
                format: date-time
                description: Login attempt timestamp
                example: '2024-01-15T14:22:00Z'
              ip_address:
                type: string
                description: IP address of login attempt
                example: 192.168.1.100
              location:
                type: string
                nullable: true
                description: Geographic location of login
                example: New York, NY
              device:
                type: string
                description: Device information
                example: iPhone 15 Pro
              success:
                type: boolean
                description: Whether login was successful
                example: true
              failure_reason:
                type: string
                nullable: true
                description: Reason for login failure
                example:
        password_security:
          type: object
          properties:
            last_changed:
              type: string
              format: date-time
              nullable: true
              description: When password was last changed
              example: '2024-01-01T00:00:00Z'
            strength_score:
              type: integer
              description: Password strength score (0-100)
              example: 85
            expires_at:
              type: string
              format: date-time
              nullable: true
              description: When password expires
              example:
    AccountSettingsDashboard:
      type: object
      description: Account settings dashboard overview
      properties:
        profile_completion:
          type: object
          properties:
            percentage:
              type: integer
              description: Profile completion percentage
              example: 85
            missing_fields:
              type: array
              items:
                type: string
              description: List of missing profile fields
              example:
              - emergency_contact
              - skills
            next_steps:
              type: array
              items:
                type: object
                properties:
                  action:
                    type: string
                    description: Action identifier
                    example: complete_emergency_contact
                  title:
                    type: string
                    description: Action title
                    example: Add Emergency Contact
                  description:
                    type: string
                    description: Action description
                    example: Add an emergency contact for safety
                  priority:
                    type: string
                    enum:
                    - high
                    - medium
                    - low
                    description: Action priority
                    example: high
        security_status:
          type: object
          properties:
            score:
              type: integer
              description: Security score (0-100)
              example: 90
            two_factor_enabled:
              type: boolean
              description: Whether 2FA is enabled
              example: true
            password_strength:
              type: string
              enum:
              - weak
              - medium
              - strong
              description: Password strength assessment
              example: strong
            recommendations:
              type: array
              items:
                type: string
              description: Security recommendations
              example:
              - Review trusted devices
        notification_summary:
          type: object
          properties:
            total_categories:
              type: integer
              description: Total notification categories
              example: 12
            enabled_categories:
              type: integer
              description: Number of enabled categories
              example: 8
            unread_count:
              type: integer
              description: Number of unread notifications
              example: 3
            last_notification:
              type: string
              format: date-time
              nullable: true
              description: Timestamp of last notification
              example: '2024-01-15T10:30:00Z'
        recent_activity:
          type: array
          items:
            type: object
            properties:
              action:
                type: string
                description: Activity action
                example: profile_updated
              timestamp:
                type: string
                format: date-time
                description: Activity timestamp
                example: '2024-01-15T10:30:00Z'
              description:
                type: string
                description: Activity description
                example: Updated phone number
              ip_address:
                type: string
                nullable: true
                description: IP address of activity
                example: 192.168.1.100
        quick_actions:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                description: Action identifier
                example: upload_profile_photo
              title:
                type: string
                description: Action title
                example: Upload Profile Photo
              description:
                type: string
                description: Action description
                example: Personalize your account
              icon:
                type: string
                description: Action icon
                example: camera
              priority:
                type: string
                enum:
                - high
                - medium
                - low
                description: Action priority
                example: medium
    UserPrivacySettings:
      type: object
      description: User privacy and data management settings
      properties:
        data_sharing:
          type: object
          properties:
            analytics_enabled:
              type: boolean
              description: Whether analytics data sharing is enabled
              example: true
            performance_tracking:
              type: boolean
              description: Whether performance tracking is enabled
              example: true
            location_sharing:
              type: boolean
              description: Whether location sharing is enabled
              example: false
        visibility_settings:
          type: object
          properties:
            profile_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
              description: Profile visibility level
              example: team_only
            contact_info_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
              description: Contact information visibility
              example: managers_only
            schedule_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
              description: Schedule visibility level
              example: public
        data_retention:
          type: object
          properties:
            auto_delete_old_data:
              type: boolean
              description: Whether to automatically delete old data
              example: false
            retention_period_months:
              type: integer
              description: Data retention period in months
              example: 24
        export_options:
          type: object
          properties:
            last_export:
              type: string
              format: date-time
              nullable: true
              description: When data was last exported
              example: '2024-01-01T00:00:00Z'
            available_formats:
              type: array
              items:
                type: string
              description: Available export formats
              example:
              - json
              - csv
              - pdf
    UserPrivacySettingsUpdate:
      type: object
      description: Privacy settings update payload
      properties:
        data_sharing:
          type: object
          properties:
            analytics_enabled:
              type: boolean
            performance_tracking:
              type: boolean
            location_sharing:
              type: boolean
        visibility_settings:
          type: object
          properties:
            profile_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
            contact_info_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
            schedule_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
        data_retention:
          type: object
          properties:
            auto_delete_old_data:
              type: boolean
            retention_period_months:
              type: integer
    UserCommunicationSettings:
      type: object
      description: User communication preferences
      properties:
        preferred_language:
          type: string
          description: User's preferred language
          example: en
        timezone:
          type: string
          description: User's timezone
          example: America/New_York
        communication_style:
          type: object
          properties:
            formality:
              type: string
              enum:
              - casual
              - professional
              - formal
              description: Communication formality level
              example: professional
            frequency:
              type: string
              enum:
              - minimal
              - normal
              - frequent
              description: Communication frequency preference
              example: normal
            channel_preference:
              type: string
              enum:
              - email
              - sms
              - in_app
              - phone
              description: Preferred communication channel
              example: email
        ai_assistant:
          type: object
          properties:
            enabled:
              type: boolean
              description: Whether AI assistant is enabled
              example: true
            personality:
              type: string
              enum:
              - helpful
              - concise
              - detailed
              - friendly
              description: AI assistant personality
              example: helpful
            proactivity_level:
              type: string
              enum:
              - low
              - medium
              - high
              description: AI assistant proactivity level
              example: medium
        meeting_preferences:
          type: object
          properties:
            preferred_times:
              type: array
              items:
                type: string
              description: Preferred meeting time slots
              example:
              - '09:00-11:00'
              - 14:00-16:00
            buffer_time_minutes:
              type: integer
              description: Buffer time between meetings in minutes
              example: 15
            virtual_meeting_preference:
              type: boolean
              description: Preference for virtual meetings
              example: true
    UserCommunicationSettingsUpdate:
      type: object
      description: Communication settings update payload
      properties:
        preferred_language:
          type: string
        timezone:
          type: string
        communication_style:
          type: object
          properties:
            formality:
              type: string
              enum:
              - casual
              - professional
              - formal
            frequency:
              type: string
              enum:
              - minimal
              - normal
              - frequent
            channel_preference:
              type: string
              enum:
              - email
              - sms
              - in_app
              - phone
        ai_assistant:
          type: object
          properties:
            enabled:
              type: boolean
            personality:
              type: string
              enum:
              - helpful
              - concise
              - detailed
              - friendly
            proactivity_level:
              type: string
              enum:
              - low
              - medium
              - high
        meeting_preferences:
          type: object
          properties:
            preferred_times:
              type: array
              items:
                type: string
            buffer_time_minutes:
              type: integer
            virtual_meeting_preference:
              type: boolean
    MobileAppMetadata:
      type: object
      properties:
        business:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: Office Chat Solutions
            subdomain:
              type: string
              example: officechat
            logo_url:
              type: string
              nullable: true
              example: https://cdn.workforce.mangoapps.com/logos/officechat.png
            timezone:
              type: string
              example: America/New_York
            branding:
              "$ref": "#/components/schemas/Business/properties/branding"
        login_methods:
          type: object
          properties:
            email_password:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                forgot_password_enabled:
                  type: boolean
                  example: true
            sso_providers:
              type: array
              description: |
                Array of configured SSO providers for this business. Multiple providers of the same
                type (e.g., multiple Google OAuth2 configurations) are supported. Each provider has
                a unique ID that should be passed to the SSO initiate endpoint.
              items:
                type: object
                properties:
                  id:
                    type: string
                    description: Unique identifier for this SSO configuration (use
                      as provider_id)
                    example: '67'
                  provider_type:
                    type: string
                    enum:
                    - google_oauth2
                    - saml
                    - entra_id
                    - mangoapps
                    example: google_oauth2
                  provider_name:
                    type: string
                    description: Display name for this SSO configuration
                    example: Google Workspace
                  enabled:
                    type: boolean
                    description: Whether this SSO provider is currently enabled
                    example: true
                  logo_url:
                    type: string
                    description: URL to provider logo image
                    example: https://cdn.workforce.mangoapps.com/logos/google.png
                  description:
                    type: string
                    description: User-friendly description of this SSO provider
                    example: Sign in with your Google Workspace account
                  channel_support:
                    type: string
                    enum:
                    - web_only
                    - mobile_only
                    - both
                    description: Where this provider is enabled
                    example: both
                  mobile_supported:
                    type: boolean
                    description: Convenience flag indicating mobile support
                    example: true
                  web_supported:
                    type: boolean
                    description: Convenience flag indicating web support
                    example: true
              example:
              - id: '67'
                provider_type: google_oauth2
                provider_name: Google Workspace - Corporate
                enabled: true
                logo_url: https://cdn.workforce.mangoapps.com/logos/google.png
                description: Sign in with your corporate Google account
                channel_support: both
                mobile_supported: true
                web_supported: true
              - id: '134'
                provider_type: google_oauth2
                provider_name: Google Workspace - Partners
                enabled: true
                logo_url: https://cdn.workforce.mangoapps.com/logos/google.png
                description: Sign in with your partner Google account
                channel_support: mobile_only
                mobile_supported: true
                web_supported: false
              - id: '201'
                provider_type: saml
                provider_name: Okta SAML
                enabled: true
                logo_url: https://cdn.workforce.mangoapps.com/logos/okta.png
                description: Sign in with your organization's SAML identity provider
                channel_support: web_only
                mobile_supported: false
                web_supported: true
            two_factor:
              type: object
              properties:
                available:
                  type: boolean
                  example: true
                  description: Whether 2FA is available to users
                enforced:
                  type: boolean
                  example: false
                  description: Whether 2FA is required for all users
            biometric_login:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                methods:
                  type: array
                  items:
                    type: string
                    enum:
                    - face_id
                    - touch_id
                    - fingerprint
                  example:
                  - face_id
                  - touch_id
                  - fingerprint
            remember_device:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                duration_days:
                  type: integer
                  example: 30
                api_endpoints:
                  type: object
                  properties:
                    login:
                      type: string
                      example: "/api/v1/auth/login"
                    sso_initiate:
                      type: string
                      example: "/api/v1/auth/sso/initiate"
                    sso_exchange:
                      type: string
                      example: "/api/v1/auth/sso/exchange"
                    refresh_token:
                      type: string
                      example: "/api/v1/auth/refresh"
                    logout:
                      type: string
                      example: "/api/v1/auth/logout"
                    passwordless_request:
                      type: string
                      example: "/api/v1/auth/passwordless/request"
                    passwordless_verify:
                      type: string
                      example: "/api/v1/auth/passwordless/verify"
    InspectionStatus:
      type: string
      enum:
      - draft
      - scheduled
      - in_progress
      - in_review
      - completed
      - cancelled
    ComplianceStatus:
      type: string
      enum:
      - pending
      - passed
      - failed
      - na
      - skipped
    FailureSeverity:
      type: string
      enum:
      - minor
      - moderate
      - severe
      - critical
    InspectionItemType:
      type: string
      description: |
        Renderer hint for the field. Mirrors
        `InspectionTemplateItem#item_type`.
      enum:
      - yes_no
      - checkbox
      - rating
      - text
      - number
      - slider
      - datetime
      - photo
      - signature
      - select
      - multi_select
      - instruction
    InspectionSummary:
      type: object
      description: |
        Lightweight list row. Source: `InspectionSummarySerializer`.
        Returned by GET /inspections/inspections and the embedded
        `template`/`inspector`/`location` blocks.
      properties:
        id:
          type: integer
          example: 421
        title:
          type: string
          example: Loading dock walkthrough
          nullable: true
        status:
          "$ref": "#/components/schemas/InspectionStatus"
        display_status:
          type: string
          nullable: true
          description: |
            Human-readable badge label. Normally the lifecycle status
            ("Draft"/"Scheduled"/"In Progress"/"Passed"/"Failed"/"Cancelled"),
            but returns `"Overdue"` when `overdue` is true so the client can
            flag urgency. The raw lifecycle value is always on `status`.
          example: In Progress
        status_color:
          type: string
          description: |
            Bootstrap contextual color token for the status badge — matches the
            web UI's `bg-<status_color>` class so native clients render the same
            chip coloring. Returns `danger` when `overdue` is true.
          enum:
          - secondary
          - info
          - warning
          - success
          - danger
          - dark
          example: warning
        passed:
          type: boolean
          nullable: true
        score:
          type: number
          nullable: true
          example: 92.5
        template:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 12
            name:
              type: string
              example: General Safety Inspection
            category:
              type: string
              nullable: true
              example: safety
        inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 8
            name:
              type: string
              example: Jane Doe
            photo_url:
              type: string
              description: |
                Avatar URL for the inspector (web-parity). Falls back to a
                generated ui-avatars.com initial tile when no profile photo
                is attached, so this field is always present and renderable.
              example: "/rails/active_storage/representations/redirect/.../avatar.jpg"
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 3
            name:
              type: string
              example: Plant 3
        due_at:
          type: string
          format: date-time
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        progress_percentage:
          type: integer
          nullable: true
          example: 67
        overdue:
          type: boolean
          example: false
        review_reason:
          type: string
          nullable: true
          description: |
            Latest approve/reject reviewer comment on the inspection's approval
            workflow, so a list row can show why it was approved or returned for
            rework. Null when there is no decision yet (pending review, never
            reviewed, or no workflow attached). Source: `ApprovalAction#comments`.
        current_level:
          type: integer
          nullable: true
          description: |
            1-based approval level the request currently sits at. Non-null only
            while the inspection is awaiting a decision (`in_review`); null
            otherwise.
          example: 1
        pending_with:
          type: string
          nullable: true
          description: |
            Display name of the level the inspection is awaiting a decision at
            (e.g. "Manager Review"), so the reviewer queue can render
            "Pending with <level>" without a detail fetch. Falls back to
            "Level N" when the level has no name. Null when not awaiting a
            decision.
          example: Manager Review
      required:
      - id
      - status
      - created_at
      - updated_at
    InspectionLocationFilter:
      type: object
      description: |
        Team-feed location dropdown payload. Source:
        `Api::V1::Inspections::InspectionsController#team_location_filter`,
        backed by `Inspections::TeamScopeService#available_groups`. Surfaced as
        the top-level `location_filter` key on `GET /inspections/inspections`
        when `team=true`. `options` is the permission-scoped set the caller may
        filter by (their location assignments plus locations of inspections they
        can already see) — not merely the locations present on the current page.
      properties:
        axis:
          type: string
          description: |
            The tenant's `team_inspections_scope`. `options` is only a meaningful
            location list when this is `location`; for `department`/`none` the
            client should hide the location filter.
          enum:
          - location
          - department
          - none
          example: location
        selected_id:
          type: integer
          nullable: true
          description: The currently-applied `location_id`, echoed back. Null for
            the default "all my locations" view.
          example: 3
        options:
          type: array
          description: Locations the caller may filter by. Render after an "All my
            locations" sentinel.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 3
              name:
                type: string
                example: Plant 3
      required:
      - axis
      - options
    InspectionItemMediaItem:
      type: object
      description: |
        Polymorphic `MediaItem` row attached to an inspection item — photo
        OR video. `media_kind` distinguishes the two. Source:
        `InspectionItemSerializer#media_item_payload`. Replaces the legacy
        `InspectionItemPhoto` / `InspectionItemVideo` blob shapes (which
        flattened Active Storage blobs); evidence now flows through the
        `MediaItem` model and is uploaded via the direct-upload + attach
        flow described in `POST /api/v1/inspections/uploads/direct`.
      properties:
        id:
          type: integer
        media_kind:
          type: string
          enum:
          - photo
          - video
          example: photo
        content_type:
          type: string
          example: image/jpeg
        byte_size:
          type: integer
          example: 2097152
        original_filename:
          type: string
          nullable: true
          example: photo-1716499200.jpg
        url:
          type: string
          example: "/rails/active_storage/blobs/redirect/abc/photo.jpg"
        thumb_url:
          type: string
          nullable: true
          example: "/rails/active_storage/representations/.../photo.jpg"
        has_annotations:
          type: boolean
          example: false
        captured_at:
          type: string
          format: date-time
          nullable: true
      required:
      - id
      - media_kind
      - content_type
      - byte_size
      - url
    InspectionItemTemplateSnapshot:
      type: object
      description: |
        Subset of the parent template-item fields the inspection-item
        serializer inlines so the renderer doesn't need a second fetch.
      properties:
        id:
          type: integer
        item_type:
          "$ref": "#/components/schemas/InspectionItemType"
        description:
          type: string
          nullable: true
        requires_photo:
          type: boolean
        invert_yes_no:
          type: boolean
          example: false
          description: |
            When true the yes/no question is inverted — "No" is the compliant
            answer and "Yes" fails. Renderers flip the answer chips and the
            scoring engine flips pass/fail.
        min_value:
          type: number
          nullable: true
        max_value:
          type: number
          nullable: true
        options:
          type: object
          nullable: true
          additionalProperties: true
        form_conditions:
          "$ref": "#/components/schemas/InspectionFormConditions"
    InspectionItem:
      type: object
      description: |
        One row from the inspection's `items` array. Source:
        `InspectionItemSerializer`. The `template_item` block carries the
        item-type metadata; `compliance_status` carries the user's answer.
      properties:
        id:
          type: integer
        inspection_id:
          type: integer
        template_item_id:
          type: integer
          nullable: true
        name:
          type: string
          example: Tires properly inflated
        section_name:
          type: string
          nullable: true
          example: Pre-Start Safety
        position:
          type: integer
          example: 0
        weight:
          type: number
          example: 1.0
        critical:
          type: boolean
          example: false
        compliance_status:
          "$ref": "#/components/schemas/ComplianceStatus"
        response_value:
          type: string
          nullable: true
          example: 'Yes'
        notes:
          type: string
          nullable: true
        failure_severity:
          "$ref": "#/components/schemas/FailureSeverity"
          nullable: true
        score:
          type: number
          nullable: true
          example: 0.95
        responded_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
        template_item:
          "$ref": "#/components/schemas/InspectionItemTemplateSnapshot"
          nullable: true
        media_items:
          type: array
          description: |
            Photos + videos attached to this item, in `MediaItem#ordered`
            sequence (the same order the web inspector renders). Photos
            and videos are intermixed; filter client-side by `media_kind`
            if a kind-specific view is needed.
          items:
            "$ref": "#/components/schemas/InspectionItemMediaItem"
        media_count:
          type: integer
          example: 2
          description: Total media (photos + videos).
        photo_count:
          type: integer
          example: 2
        video_count:
          type: integer
          example: 0
      required:
      - id
      - inspection_id
      - name
      - position
      - compliance_status
      - updated_at
    InspectionGps:
      type: object
      description: GPS capture stamped on the inspection at submit time.
      properties:
        latitude:
          type: number
          nullable: true
          example: 40.7128
        longitude:
          type: number
          nullable: true
          example: -74.006
        accuracy:
          type: number
          nullable: true
          example: 5.2
        address:
          type: string
          nullable: true
          example: New York, NY
        captured_at:
          type: string
          format: date-time
          nullable: true
    InspectionApproval:
      type: object
      description: |
        Approval-workflow state when the inspection is `in_review`. The whole
        block is omitted for a non-reviewer's pending request; `can_review` is
        true only for an eligible reviewer at the current level.
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - escalated
          - auto_approved
          - cancelled
        current_level:
          type: integer
          example: 1
        requested_at:
          type: string
          format: date-time
          nullable: true
        workflow_id:
          type: integer
          nullable: true
        can_review:
          type: boolean
          description: |
            True when the current caller may approve/reject at the current
            level. Gate the Approve/Reject affordances on this flag
            (server-authoritative), not on client-side role inference.
          example: true
        last_action:
          type: object
          nullable: true
          description: Most recent decision (approve/reject) on this request; null
            before any decision.
          properties:
            action:
              type: string
              enum:
              - approved
              - rejected
              example: rejected
            reason:
              type: string
              nullable: true
              description: Reviewer comment (required on reject).
            acted_at:
              type: string
              format: date-time
              nullable: true
            acted_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
    InspectionUserRef:
      type: object
      nullable: true
      description: Lightweight user reference. `name` is the user's display name;
        null when no actor is recorded.
      properties:
        id:
          type: integer
          nullable: true
        name:
          type: string
          nullable: true
    InspectionReviewTimeline:
      type: object
      description: |
        Read-only review timeline — the same submitted → decisions → awaiting →
        upcoming → terminal sequence the desktop "Review Timeline" card renders,
        plus prior rework rounds. Unlike `approval` (reviewer-gated for action
        affordances), this block is visible to everyone who can view the
        inspection. The whole object is null when the inspection never entered
        review and carries no workflow_history audit trail.

        Two modes:
          - Approval-backed: `workflow` is present and `nodes` use the typed
            entries below (submitted/decision/awaiting/upcoming/terminal).
          - Legacy / no-workflow completion: `workflow`, `status`, and
            `current_level` are null and `nodes` are `event` entries built from
            the inspection's workflow_history audit trail.
      properties:
        status:
          type: string
          nullable: true
          enum:
          - pending
          - approved
          - rejected
          - escalated
          - auto_approved
          - cancelled
          description: Latest ApprovalRequest status; null in the workflow_history
            fallback.
        current_level:
          type: integer
          nullable: true
        workflow:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            max_levels:
              type: integer
              example: 2
        nodes:
          type: array
          description: Ordered timeline entries. Render by `type`.
          items:
            type: object
            description: |
              One of six shapes, discriminated by `type`:
                - submitted: { type, label, by, at }
                - decision:  { type, status, label, level_number, level_name, by, at, response_time_hours, comments }
                - awaiting:  { type, label, level_number, level_name, step, total_steps, eligible_reviewers[] }
                - upcoming:  { type, label, level_number, level_name }
                - terminal:  { type, status, label, completed_at }
                - event:     { type, action, label, by, at, comments }  # workflow_history fallback
            properties:
              type:
                type: string
                enum:
                - submitted
                - decision
                - awaiting
                - upcoming
                - terminal
                - event
              label:
                type: string
              status:
                type: string
                nullable: true
                description: 'decision: approved|rejected|escalated|auto_approved
                  · terminal: approved|rejected|escalated|cancelled'
              action:
                type: string
                nullable: true
                description: Raw action for `event` nodes (e.g. submitted, completed,
                  returned).
              level_number:
                type: integer
                nullable: true
              level_name:
                type: string
                nullable: true
              step:
                type: integer
                nullable: true
              total_steps:
                type: integer
                nullable: true
              response_time_hours:
                type: number
                nullable: true
                example: 14.73
              comments:
                type: string
                nullable: true
                description: Null when blank — never an empty string.
              completed_at:
                type: string
                format: date-time
                nullable: true
              at:
                type: string
                format: date-time
                nullable: true
              by:
                "$ref": "#/components/schemas/InspectionUserRef"
              eligible_reviewers:
                type: array
                description: Present on `awaiting` nodes — all reviewers eligible
                  at the current level.
                items:
                  "$ref": "#/components/schemas/InspectionUserRef"
        previous_rounds:
          type: array
          description: Earlier rework loops, most-recent first. Empty in the fallback.
          items:
            type: object
            properties:
              round:
                type: integer
                description: Chronological index (1 = oldest).
              status:
                type: string
                enum:
                - pending
                - approved
                - rejected
                - escalated
                - auto_approved
                - cancelled
              submitted_by:
                "$ref": "#/components/schemas/InspectionUserRef"
              submitted_at:
                type: string
                format: date-time
                nullable: true
              actions:
                type: array
                items:
                  type: object
                  properties:
                    action:
                      type: string
                      example: rejected
                    by:
                      "$ref": "#/components/schemas/InspectionUserRef"
                    level_number:
                      type: integer
                      nullable: true
                    level_name:
                      type: string
                      nullable: true
                    comments:
                      type: string
                      nullable: true
                    acted_at:
                      type: string
                      format: date-time
                      nullable: true
    InspectionAsset:
      type: object
      description: AssetPro asset (equipment) linked to the inspection.
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
          example: Toyota 8FGCU25 — FL-04
        condition:
          type: string
          nullable: true
          enum:
          - excellent
          - good
          - fair
          - poor
          - damaged
          - unknown
    InspectionMetadata:
      type: object
      description: |
        Sanitized client-side metadata (the full `metadata` JSONB is not
        surfaced to mobile to keep the workflow_history audit log private).
      properties:
        device_info:
          type: string
          nullable: true
        app_version:
          type: string
          nullable: true
        offline_created:
          type: boolean
          nullable: true
        synced_at:
          type: string
          format: date-time
          nullable: true
        weather_conditions:
          type: string
          nullable: true
    InspectionCorrectiveActionUser:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          example: Jane Doe
    InspectionCorrectiveAction:
      type: object
      description: |
        Auto-generated follow-up task created from a failed item. Source:
        `CorrectiveActionSerializer`. Links back to the inspection item
        that produced it; can also be linked to the Tasks app via `task_id`.
      properties:
        id:
          type: integer
        inspection_id:
          type: integer
        inspection_item_id:
          type: integer
          nullable: true
        description:
          type: string
          example: Replace damaged eyewash bottle.
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        due_date:
          type: string
          format: date
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        resolution_notes:
          type: string
          nullable: true
        assigned_to:
          "$ref": "#/components/schemas/InspectionCorrectiveActionUser"
          nullable: true
        created_by:
          "$ref": "#/components/schemas/InspectionCorrectiveActionUser"
          nullable: true
        inspection:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            title:
              type: string
              nullable: true
            url:
              type: string
              description: |
                Mobile-web deep link (`/m/apps/inspections/<id>`) — native
                clients open the inspection read-only inside the in-app
                WebView, matching the inspections list `url`.
        inspection_item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            section_name:
              type: string
              nullable: true
        overdue:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    InspectionDetail:
      allOf:
      - "$ref": "#/components/schemas/InspectionSummary"
      - type: object
        description: |
          Full inspection payload used by show / create / complete / cancel.
          Adds notes, GPS, items, corrective actions, approval state, and
          sanitized metadata. Source: `InspectionDetailSerializer`.
        properties:
          notes:
            type: string
            nullable: true
          summary:
            type: string
            nullable: true
          ai_summary:
            type: string
            nullable: true
            description: |
              AI-generated executive summary stamped on submit. Null until the
              background summarizer finishes, or when AI summarization is
              disabled for the tenant.
          inspector_signature:
            type: string
            nullable: true
            description: Stored URL or data-uri for the captured signature.
          source_type:
            type: string
            nullable: true
            enum:
            - manual
            - scheduled
            - asset_requirement
          duration_seconds:
            type: integer
            nullable: true
          gps:
            "$ref": "#/components/schemas/InspectionGps"
          score_breakdown:
            type: object
            nullable: true
            additionalProperties: true
          sections:
            type: array
            items:
              type: string
          items:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionItem"
          corrective_actions:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionCorrectiveAction"
          approval:
            "$ref": "#/components/schemas/InspectionApproval"
            nullable: true
          review_timeline:
            "$ref": "#/components/schemas/InspectionReviewTimeline"
            nullable: true
          asset:
            "$ref": "#/components/schemas/InspectionAsset"
            nullable: true
          metadata:
            "$ref": "#/components/schemas/InspectionMetadata"
          can_delete:
            type: boolean
            description: |
              True when the current caller may delete this inspection. Reflects
              BOTH gates the DELETE endpoint enforces: the per-business
              `allow_delete_inspections` setting (off by default) AND modify
              authority (the assigned inspector or a manager/inspections-admin).
              False when deletion is disabled business-wide, so the native
              client should hide the Delete affordance. Emitted on the show
              payload only. Source: `InspectionsController#can_delete_inspection?`.
          can_take_over:
            type: boolean
            description: |
              True when the current caller may take over this in-progress
              inspection (or pick up a scheduled one, when the tenant allows
              pickup) — i.e. a manager/admin or a peer assigned to the
              inspection's location who is not already the inspector. Drives
              the native "Continue"/"Pick up this inspection" affordance.
              Source: `Inspection#takeable_by?`.
          can_review:
            type: boolean
            description: |
              True when the current caller may approve/reject this inspection
              right now — i.e. it is in review and the caller is an eligible
              reviewer at the current approval level (submitter excluded by
              separation of duties). Drives the native "Review" affordance.
              Always present on the detail payload (false when nothing is in
              review or the caller isn't eligible) — unlike the reviewer-gated
              `approval.can_review`, which is omitted with the whole `approval`
              block for a non-reviewer's pending request. Source:
              `InspectionDetailSerializer#can_review?` →
              `ApprovalLevel#can_approve?`.
          last_activity_at:
            type: string
            format: date-time
            nullable: true
            description: |
              Most-recent activity across the inspection record, its items
              (status/response/notes edits), and their media. The "Last
              updated" recency signal for the takeover decision — reliable
              where `updated_at` alone is not, since item edits don't touch
              the parent inspection. Source: `Inspection#last_activity_at`.
          template:
            type: object
            nullable: true
            description: |
              On the detail payload the nested template block is enriched
              (beyond the summary's id/name/category) with `require_signature`
              and the per-template failure follow-up prompt config, so the
              conduct/complete screen can decide whether to prompt for a
              signature and drive the failure-capture modal without a second
              call to the template detail/bundle endpoint.
              Source: `InspectionDetailSerializer`.
            properties:
              id:
                type: integer
                example: 12
              name:
                type: string
                example: General Safety Inspection
              category:
                type: string
                nullable: true
                example: safety
              require_signature:
                type: boolean
                description: |
                  Whether the inspector must capture a digital signature
                  before completing. When false the native client skips the
                  signature step. Same canonical
                  `InspectionTemplate#require_signature?` predicate the
                  template summary/detail payloads expose.
              failure_prompt_settings:
                type: object
                nullable: true
                description: |
                  Per-template controls for the failure-capture modal:
                  `prompt_enabled` (boolean master toggle) plus `prompt_photo`,
                  `prompt_severity`, `prompt_comment`, `prompt_action_item`
                  (each "off" | "optional" | "required"). Null when the
                  inspection has no template; an empty object `{}` when the
                  template exists but was never configured — clients resolve a
                  missing master → false and any missing field → "off".
                additionalProperties: true
    InspectionTemplateSummary:
      type: object
      description: |
        Compact template row for the picker. Source:
        `TemplateSummarySerializer`.
      properties:
        id:
          type: integer
          example: 12
        name:
          type: string
          example: Forklift Daily Pre-Use
        description:
          type: string
          nullable: true
          example: Daily pre-shift safety check…
        category:
          type: string
          nullable: true
          example: safety
        inspection_type:
          type: string
          nullable: true
          example: equipment
        pass_threshold:
          type: number
          nullable: true
          example: 80.0
        estimated_duration_minutes:
          type: integer
          nullable: true
          description: |
            Approximate time to complete the inspection (minutes). Authored on
            the template; surfaced on the list row so the picker can show
            "~15 min" without a follow-up detail call.
          example: 15
        item_count:
          type: integer
          example: 13
        inspections_count:
          type: integer
          example: 142
        require_signature:
          type: boolean
        requires_training:
          type: boolean
        has_approval_workflow:
          type: boolean
        active:
          type: boolean
        is_system_template:
          type: boolean
        updated_at:
          type: string
          format: date-time
      required:
      - id
      - name
      - active
      - updated_at
    InspectionTemplateItem:
      type: object
      description: |
        One field in a template definition. Source: the inline
        `template_item_payload` in `TemplateDetailSerializer`.
      properties:
        id:
          type: integer
        position:
          type: integer
          example: 0
        section_name:
          type: string
          nullable: true
          example: Pre-Start Safety Checks
        name:
          type: string
          example: Tires properly inflated
        description:
          type: string
          nullable: true
        item_type:
          "$ref": "#/components/schemas/InspectionItemType"
        weight:
          type: number
          example: 1.0
        critical:
          type: boolean
          example: false
        requires_photo:
          type: boolean
          example: false
        auto_fail_on_no:
          type: boolean
          example: false
        invert_yes_no:
          type: boolean
          example: false
          description: |
            When true the yes/no question is inverted — "No" is the compliant
            answer and "Yes" fails. Renderers flip the answer chips and the
            scoring engine flips pass/fail.
        min_value:
          type: number
          nullable: true
        max_value:
          type: number
          nullable: true
        options:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            Item-type-specific configuration. For `select`/`multi_select`
            this is `{ choices: [{ value, label }] }`. For `number`/`rating`
            it may include `unit`, `step`, etc. For `slider` it pairs with
            `min_value`/`max_value`.
        form_conditions:
          "$ref": "#/components/schemas/InspectionFormConditions"
      required:
      - id
      - position
      - name
      - item_type
    InspectionFormConditions:
      type: object
      nullable: true
      additionalProperties: true
      description: |
        FormKit conditional-logic contract for a branching template item, or
        null for unconditional items. Shape:

            {
              "show_when":    [ { "field": "item_<parent_template_item_id>",
                                  "operator": "equals" | "in_list",
                                  "value": "yes" | ["opt_a", "opt_b"] } ],
              "hide_when":    [],
              "require_when": [ ...same rule shape... ]
            }

        Evaluation semantics (mirror the server's FormKit::ConditionEvaluator
        and the web client's form_conditions.js — clients must match):
        - `field` refers to another item in the same template, keyed
          `item_<template_item_id>`. Compare against the item's ANSWER value —
          for `yes_no`/`checkbox` items that is the raw `"yes"`/`"no"`/`"na"`
          string (NOT compliance_status; inverted items still answer
          "yes"/"no"), for every other type the literal `response_value`;
          unanswered compares as `""`.
        - Multiple rules in one array are AND-ed.
        - Visibility: non-empty `show_when` decides; else non-empty
          `hide_when` inverts; else visible. A hidden item is never required,
          is excluded from scoring/progress, and does not block completion.
          Hidden items' answers are treated as blank when evaluating OTHER
          items' rules (chained conditions).
        - Required: non-empty `require_when` decides (only while visible);
          otherwise the item is required by default.
        - The server currently emits only `equals` and `in_list`, but the
          evaluator accepts the full operator set: equals, not_equals,
          contains, not_contains, greater_than, less_than,
          greater_than_or_equal, less_than_or_equal, is_empty, is_not_empty,
          in_list, not_in_list, starts_with, ends_with, matches_pattern.
          Numeric operators coerce with Ruby `to_f` semantics
          (nil/blank/non-numeric → 0). Unknown operators evaluate false.
        - String comparisons are CASE-INSENSITIVE: `equals`/`not_equals`/
          `in_list`/`not_in_list` also trim surrounding whitespace;
          `contains`/`starts_with`/`ends_with` fold case only.
          `matches_pattern` stays case-sensitive. Clients must implement the
          same folding.
        - A `multi_select` parent's answer evaluates as an ARRAY of the
          selected values: `equals`/`contains`/`in_list` match when ANY
          selected value matches (server and clients agree on this).
    InspectionTemplateDetail:
      allOf:
      - "$ref": "#/components/schemas/InspectionTemplateSummary"
      - type: object
        description: |
          Full template payload used by GET /templates/:id. Adds the
          items array, sections order, estimated duration, and the
          template-level failure-prompt settings the renderer uses to
          drive the failure modal.
        properties:
          description:
            type: string
            nullable: true
          estimated_duration_minutes:
            type: integer
            nullable: true
          sections_order:
            type: array
            items:
              type: string
          failure_prompt_settings:
            type: object
            nullable: true
            description: |
              Per-template controls for the failure-capture modal.
              Keys map to `prompt_enabled`, `prompt_photo`, `prompt_severity`,
              `prompt_comment`, `prompt_action_item`.
            additionalProperties: true
          max_photos_per_item:
            type: integer
            nullable: true
            description: |
              App-level cap (Evidence & Input setting) on photos per
              inspection item. Null when unset; clients should apply their
              own default. Same value for every item in the template.
          items:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionTemplateItem"
    InspectionCreateItem:
      type: object
      description: |
        Item update payload submitted as part of `POST /inspections`. The
        server matches it to an existing `inspection_item` by `id`
        (preferred — used in the sync flow) or by `template_item_id`
        (used in the create-from-template flow, where the inspection_items
        are auto-created by the model's `after_create` callback).
      properties:
        id:
          type: integer
          nullable: true
          description: Inspection item id (sync flow only).
        template_item_id:
          type: integer
          nullable: true
          description: Template item id (create flow).
        compliance_status:
          "$ref": "#/components/schemas/ComplianceStatus"
        response_value:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        failure_severity:
          "$ref": "#/components/schemas/FailureSeverity"
          nullable: true
    InspectionCreateMediaUpload:
      type: object
      description: |
        Refers to an Active Storage blob the client already PUT to S3 via
        `/inspections/direct_uploads`. Attaches it to a specific
        inspection item once the inspection is created. Used when the
        client wants to upload media before knowing the inspection id —
        in the normal flow callers use the per-item photos endpoint instead.
      properties:
        item_id:
          type: integer
        blob_signed_id:
          type: string
          example: eyJfcmFpbHMi...
        kind:
          type: string
          enum:
          - photo
          - video
          default: photo
    InspectionCreateRequest:
      type: object
      description: Body for `POST /inspections/inspections`.
      properties:
        inspection_template_id:
          type: integer
          example: 12
        title:
          type: string
          nullable: true
          example: Forklift FL-04 daily check
        location_id:
          type: integer
          nullable: true
        asset_pro_asset_id:
          type: integer
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        notes:
          type: string
          nullable: true
        gps_latitude:
          type: number
          nullable: true
        gps_longitude:
          type: number
          nullable: true
        gps_accuracy:
          type: number
          nullable: true
        gps_address:
          type: string
          nullable: true
        gps_captured_at:
          type: string
          format: date-time
          nullable: true
        items:
          type: array
          description: Optional batch of item updates (offline submissions).
          items:
            "$ref": "#/components/schemas/InspectionCreateItem"
        media_uploads:
          type: array
          description: Optional blobs to attach to specific inspection items.
          items:
            "$ref": "#/components/schemas/InspectionCreateMediaUpload"
        complete:
          type: boolean
          default: false
          description: When true, the server calls `submit!` after applying item updates.
        complete_notes:
          type: string
          nullable: true
          description: Final notes to record on the submission (used when `complete=true`).
      required:
      - inspection_template_id
    InspectionUpdateRequest:
      type: object
      description: |
        Body for `PATCH /inspections/inspections/:id`. Mirrors the desktop Edit
        Inspection form's field set exactly. Accepts the fields at the top
        level or nested under an `inspection: { ... }` key so callers can use
        either shape. `inspection_template_id` is silently rejected
        (422 `template_locked`) when the inspection is `in_progress` or
        `completed` — matches the web form, where the template select is
        disabled in those states.
      properties:
        title:
          type: string
          nullable: true
        inspection_template_id:
          type: integer
          nullable: true
          description: Locked when the inspection is in_progress or completed.
        location_id:
          type: integer
          nullable: true
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        notes:
          type: string
          nullable: true
    InspectionCorrectiveActionCreateItem:
      type: object
      description: |
        Follow-up action carried inline on the `sync` / `complete` body.
        Each entry is either an UPDATE (when `id` is supplied — must reference
        a corrective action already on this inspection) or a CREATE (any other
        case). Validation mirrors `POST /inspections/inspections/:id/corrective_actions`:
        cross-tenant `assigned_to_id`, off-inspection `inspection_item_id`,
        and out-of-allowlist priority/status all 422 the whole sync /
        complete request (atomic — no partial writes).
      properties:
        id:
          type: integer
          nullable: true
          description: Existing inspection_corrective_action id (update mode).
        description:
          type: string
          description: Required on create. Max 2000 chars.
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          default: medium
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          default: pending
        due_date:
          type: string
          format: date
          nullable: true
        assigned_to_id:
          type: integer
          nullable: true
          description: Must be a member of the calling business.
        inspection_item_id:
          type: integer
          nullable: true
          description: Must belong to the inspection being synced/completed.
        resolution_notes:
          type: string
          nullable: true
    InspectionCompleteRequest:
      type: object
      description: Body for `POST /inspections/inspections/:id/complete`.
      properties:
        signature:
          type: string
          nullable: true
          description: |
            Inspector signature, base64-encoded PNG. May be a full data
            URI (`data:image/png;base64,...`) or a bare base64 string.
        notes:
          type: string
          nullable: true
          description: Final inspector notes to attach to the submission.
        corrective_actions:
          type: array
          description: |
            Optional follow-up actions to create (or update) atomically as part
            of the completion. Persisted BEFORE the state transition so the
            post-completion fan-out (`FormIntegrationService`) sees them and
            skips auto-creating duplicates for the same failed items.
          items:
            "$ref": "#/components/schemas/InspectionCorrectiveActionCreateItem"
    InspectionCancelRequest:
      type: object
      description: Body for `POST /inspections/inspections/:id/cancel`.
      properties:
        reason:
          type: string
          nullable: true
          description: Free-form reason recorded in the audit log.
    InspectionTakeOverRequest:
      type: object
      description: Body for `POST /inspections/inspections/:id/take_over`.
      properties:
        reason:
          type: string
          nullable: true
          description: Free-form reason recorded in the reassignment audit entry.
    InspectionSyncRequest:
      type: object
      description: |
        Body for `POST /inspections/inspections/:id/sync`. Applies a batch of
        item updates + inspection-level patch fields to an in-progress
        inspection without completing it (unless `complete: true`).
      properties:
        client_id:
          type: string
          nullable: true
          description: Client-side idempotency token (typically a UUID).
        client_updated_at:
          type: string
          format: date-time
          nullable: true
          description: Client-side timestamp for last-write-wins conflict resolution.
        inspection:
          "$ref": "#/components/schemas/InspectionSyncRequestInspection"
        items:
          type: array
          description: Item updates to apply (located by `id` or `template_item_id`).
          items:
            "$ref": "#/components/schemas/InspectionCreateItem"
        media_uploads:
          type: array
          description: Direct-upload blob refs to attach to specific items.
          items:
            "$ref": "#/components/schemas/InspectionCreateMediaUpload"
        corrective_actions:
          type: array
          description: |
            Optional follow-up actions to create (or update) atomically in
            the same transaction as the item / media writes. A validation
            failure on ANY entry rolls back the entire sync (no partial
            writes). When the sync also carries `complete: true`, CAs land
            BEFORE `submit!` so the post-completion fan-out skips creating
            duplicates for the same failed items.
          items:
            "$ref": "#/components/schemas/InspectionCorrectiveActionCreateItem"
        complete:
          type: boolean
          default: false
          description: When true, call `submit!` after applying the batch.
    InspectionSyncRequestInspection:
      type: object
      description: |
        Inspection-level patch carried inside `InspectionSyncRequest.inspection`.
        Mirrors the whitelist applied by `apply_inspection_updates!` on the
        server. `signature` is read here (not at the top level) and forwarded
        to `submit!` when `complete: true`.
      properties:
        notes:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
        signature:
          type: string
          nullable: true
          description: 'Base64 or stored ref; only consumed when `complete: true`.'
        gps_latitude:
          type: number
          nullable: true
        gps_longitude:
          type: number
          nullable: true
        gps_accuracy:
          type: number
          nullable: true
        gps_address:
          type: string
          nullable: true
        gps_captured_at:
          type: string
          format: date-time
          nullable: true
    InspectionCorrectiveActionUpdateRequest:
      type: object
      description: |
        Body for `PATCH /inspections/corrective_actions/:id`. Every field
        is optional; only the keys present are applied.
      properties:
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        description:
          type: string
        due_date:
          type: string
          format: date
          nullable: true
        assigned_to_id:
          type: integer
          nullable: true
        resolution_notes:
          type: string
          nullable: true
    DirectUploadBlobRequest:
      type: object
      description: |
        Pre-upload blob descriptor. The client SHA256-hashes the raw bytes
        and sends the digest as `checksum` so Active Storage can verify
        the eventual PUT to S3.
      properties:
        blob:
          type: object
          required:
          - filename
          - content_type
          - byte_size
          - checksum
          properties:
            filename:
              type: string
              example: photo-1716499200.jpg
            content_type:
              type: string
              description: |
                Normally one of the allow-listed image or video MIME types.
                When the top-level `non_media` flag is true, this allow-list is
                bypassed and any content type is accepted (the size cap still
                applies).
              enum:
              - image/jpeg
              - image/png
              - image/gif
              - image/webp
              - image/heic
              - image/heif
              - video/mp4
              - video/webm
              - video/quicktime
              - video/x-msvideo
            byte_size:
              type: integer
              description: |
                Raw byte length. Limits: ≤ 20 MB for photos, ≤ 50 MB for
                videos and (when `non_media` is true) any other content type.
              example: 2097152
            checksum:
              type: string
              description: Base64-encoded MD5 of the raw bytes (Active Storage convention).
              example: q2QmVgL5xK+oR2VBoXcb9Q==
        non_media:
          type: boolean
          default: false
          description: |
            Optional. When true, the `blob.content_type` allow-list is bypassed
            so any file type (e.g. a PDF report or document) can be uploaded.
            When absent or false, only the allow-listed image/video types are
            accepted. The size cap always applies (photo types ≤ 20 MB,
            everything else ≤ 50 MB).
          example: false
      required:
      - blob
    DirectUploadBlobResponse:
      type: object
      description: |
        Signed URL the client PUTs raw bytes to, plus the `signed_id`
        used later to attach the blob to an inspection item.
      properties:
        signed_id:
          type: string
          example: eyJfcmFpbHMi...
        direct_upload_url:
          type: string
          example: https://example-bucket.s3.amazonaws.com/abc?X-Amz-...
        direct_upload_headers:
          type: object
          additionalProperties:
            type: string
          description: HTTP headers the client must include on the PUT.
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        checksum:
          type: string
      required:
      - signed_id
      - direct_upload_url
      - direct_upload_headers
      - filename
      - content_type
      - byte_size
    InspectionScheduleSummary:
      type: object
      description: 'Source: `Api::V1::Inspections::ScheduleSerializer`.

        '
      properties:
        id:
          type: integer
        name:
          type: string
        frequency:
          type: string
          nullable: true
        day_of_week:
          type: integer
          nullable: true
        day_of_month:
          type: integer
          nullable: true
        active:
          type: boolean
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        last_generated_at:
          type: string
          format: date-time
          nullable: true
        next_generation_at:
          type: string
          format: date-time
          nullable: true
        ready_to_generate:
          type: boolean
        template:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            category:
              type: string
              nullable: true
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        assigned_inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true
    InspectionCycleItem:
      type: object
      description: |
        A claimable cycle item on the "Available to claim" feed. An inspection
        only exists AFTER a claim, so the claim pool serializes the
        pre-inspection `InspectionCycleItem`. Source:
        `Api::V1::Inspections::CycleItemSerializer`.
      properties:
        id:
          type: integer
          description: Cycle item id.
          example: 4
        name:
          type: string
          nullable: true
          description: The thing being inspected.
          example: 'Forklift #3'
        cycle_id:
          type: integer
          example: 7
        cycle_name:
          type: string
          nullable: true
          example: Q3 Safety Sweep
        template_id:
          type: integer
          nullable: true
          example: 81
        template_name:
          type: string
          nullable: true
          example: Daily Workplace Safety Inspection
        location_id:
          type: integer
          nullable: true
          example: 2
        location_name:
          type: string
          nullable: true
          example: Northeast Region
        due_at:
          type: string
          format: date-time
          nullable: true
          description: Prospective deadline — the cycle's end-of-day (the `due_at`
            stamped on the inspection at claim time). Null when the cycle has no end
            date.
        status:
          type: string
          example: pending
        can_claim:
          type: boolean
          description: Server-authoritative. Always true in this feed (already filtered
            to the caller's claimable items).
          example: true
        claimed_by:
          type: object
          nullable: true
          description: The assigned inspector once claimed; null while in the pool.
          properties:
            id:
              type: integer
            name:
              type: string
      required:
      - id
      - cycle_id
      - status
      - can_claim
    InspectionCycleSummary:
      type: object
      description: 'Source: `Api::V1::Inspections::CycleSerializer`.

        '
      properties:
        id:
          type: integer
        name:
          type: string
        cycle_type:
          type: string
          nullable: true
        status:
          type: string
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        days_remaining:
          type: integer
          nullable: true
        progress_percentage:
          type: number
          format: float
          nullable: true
        close_behavior:
          type: string
          nullable: true
        template:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        my_inspection:
          type: object
          nullable: true
          description: The caller's in-progress or draft inspection for this cycle,
            if one exists. Lets the client show "Resume" instead of "Start". Null
            when the caller has no active inspection in the cycle.
          properties:
            id:
              type: integer
            status:
              type: string
    InspectionSweepSummary:
      type: object
      description: |
        Source: `Api::V1::Inspections::SweepSummarySerializer`. A coverage
        sweep the current inspector can work (one per facility layout).
      properties:
        id:
          type: integer
        status:
          type: string
          description: in_progress | pending_approval | signed_off
        sweep_assignment:
          type: string
          description: claim_pool | zones
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        facility_layout:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        coverage_percent:
          type: integer
          nullable: true
        aisles_covered:
          type: number
          format: float
          nullable: true
        aisles_total:
          type: integer
          nullable: true
        fully_covered:
          type: boolean
          nullable: true
        last_session:
          type: object
          nullable: true
          description: Cross-person resume teaser; null until anyone has paused a
            session.
          properties:
            range_label:
              type: string
              nullable: true
            frontier_bay:
              type: integer
              nullable: true
            handoff_note:
              type: string
              nullable: true
            paused_by:
              type: string
              nullable: true
            paused_at:
              type: string
              format: date-time
              nullable: true
        inspection_cycle:
          type: object
          nullable: true
          description: The parent cycle — its name is the "inspection name" the web
            shows (banner/breadcrumb/cockpit).
          properties:
            id:
              type: integer
            name:
              type: string
        inspection_cycle_id:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    InspectionSweepCell:
      type: object
      description: One resolved (bay, level) cell. Only passed/exception cells exist;
        a (bay,level) not present is "pending".
      properties:
        bay:
          type: integer
        level:
          type: integer
        status:
          type: string
          description: passed | exception
    InspectionSweepAisle:
      type: object
      description: 'Source: `Api::V1::Inspections::SweepAisleSerializer`. One aisle
        + its per-(bay,level) grid.'
      properties:
        id:
          type: integer
        label:
          type: string
        position:
          type: integer
        zone_name:
          type: string
          nullable: true
        status:
          type: string
          description: not_started | in_progress | covered | skipped
        bays_total:
          type: integer
        depth:
          type: integer
        frontier_bay:
          type: integer
        skip_reason:
          type: string
          nullable: true
        coverage_ratio:
          type: number
          format: float
        bay_labels:
          type: array
          items:
            type: string
        level_elevations:
          type: array
          items: {}
        current_inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        assigned_inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        cells:
          type: array
          items:
            "$ref": "#/components/schemas/InspectionSweepCell"
    InspectionSweepException:
      type: object
      description: 'Source: `Api::V1::Inspections::SweepExceptionSerializer`. A coverage-sweep
        finding.'
      properties:
        id:
          type: integer
        aisle:
          type: string
          nullable: true
        aisle_id:
          type: integer
          nullable: true
        bay:
          type: string
          nullable: true
        level:
          type: string
          nullable: true
        component:
          type: string
          nullable: true
        damage:
          type: string
          nullable: true
        severity:
          type: string
          description: Observation | Minor | Major | Critical
        status:
          type: string
          description: open | resolved | corrective | voided
        note:
          type: string
          nullable: true
        answers:
          type: object
        position_label:
          type: string
          nullable: true
        inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        photos:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              media_kind:
                type: string
              url:
                type: string
              captured_at:
                type: string
                format: date-time
                nullable: true
        created_at:
          type: string
          format: date-time
    InspectionSweepDetail:
      allOf:
      - "$ref": "#/components/schemas/InspectionSweepSummary"
      - type: object
        properties:
          can_write:
            type: boolean
          can_sign_off:
            type: boolean
          requires_approval:
            type: boolean
          aisles:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionSweepAisle"
          exceptions:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionSweepException"
    InspectionSweepBundle:
      allOf:
      - "$ref": "#/components/schemas/InspectionSweepDetail"
      - type: object
        properties:
          rack_form_schema:
            type: object
            description: Component-branched log-issue form (RackFormSchema).
          components:
            type: array
            items:
              type: string
          fetched_at:
            type: string
            format: date-time
          etag:
            type: string
    LeaderRoundsPagination:
      type: object
      description: Per-collection pagination. `/leader-rounds/my` returns three of
        these — one per collection — because it walks three independent cursors.
      required:
      - page
      - per_page
      - total
      - total_pages
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total:
          type: integer
          example: 42
        total_pages:
          type: integer
          example: 2
    LeaderRoundsRoundSummary:
      type: object
      description: A round as it appears in a list. `private_notes` is present ONLY
        for the leader who led it — on any other tier the key is absent, not null.
      required:
      - id
      - subject_id
      - subject_name
      - leader_id
      - leader_name
      - occurred_on
      - status
      properties:
        id:
          type: integer
          example: 4
        subject_id:
          type: integer
          example: 1752
        subject_name:
          type: string
          example: Marcus Bell
        subject_job_title:
          type: string
          nullable: true
          description: The subject's credential, null when the tenant records none.
            How a person is identified on a floor ("Marcus Bell, RN") — two Bells
            on one unit is normal, so a bare name is ambiguous.
          example: RN
        subject_avatar_url:
          type: string
          nullable: true
          description: Absolute URL, null when no avatar is set.
        leader_id:
          type: integer
          example: 893
        leader_name:
          type: string
          example: Priya Raman
        leader_job_title:
          type: string
          nullable: true
          description: 'Emitted alongside the subject''s because the two surfaces
            that render a round show different people: a leader''s Recent Rounds shows
            the SUBJECT, Rounds About Me shows the LEADER.'
          example: Unit Leader
        leader_avatar_url:
          type: string
          nullable: true
          description: Absolute URL, null when no avatar is set.
        template:
          type: string
          description: The template's NAME (not its id).
          example: Staff Rounding
        occurred_on:
          type: string
          format: date
          example: '2026-08-06'
        status:
          type: string
          enum:
          - draft
          - completed
          - skipped
          example: completed
        skipped_reason:
          type: string
          nullable: true
          description: Present on a skipped round. A skip is a recorded decision the
            subject can read — "subject on leave" — not a gap in coverage.
        private_notes:
          type: string
          nullable: true
          description: Leader tier ONLY. Absent from the payload entirely for the
            subject and for anyone outside the leader's management chain — see `private_notes_visible`
            on the detail response.
        answers_count:
          type: integer
          description: Answers recorded on the round. Emitted by the LIST endpoints
            (where it is batched for the page) and by the create/detail replies.
        issues_count:
          type: integer
          description: Issues the round raised onto the stoplight ledger.
        open_issues_count:
          type: integer
          description: Of `issues_count`, how many are still open. LIST endpoints
            only. A round whose three issues are all resolved and one whose three
            are still open read identically from a total, and on a log of past rounds
            that difference is the point.
    LeaderRoundsTemplateQuestion:
      type: object
      description: One ordered, typed question. `field_type` is the platform form-field
        control to render; `question_type` is the key `answers` is submitted under.
        See the mapping table on GET /leader-rounds/templates.
      required:
      - id
      - position
      - prompt
      - question_type
      - field_type
      - required
      - choices
      - composite
      - inputs
      properties:
        id:
          type: integer
          description: The key to use in the `answers` / `issues` hash on create.
          example: 5
        position:
          type: integer
          example: 4
        prompt:
          type: string
          example: How supported do you feel in your role right now?
        question_type:
          type: string
          enum:
          - text
          - scale
          - boolean
          - choice
          - recognition_pick
          - issue_capture
          example: scale
        field_type:
          type: string
          enum:
          - textarea
          - rating
          - radio
          - select
          - lookup
          description: The shared form-field control this question renders on.
          example: rating
        required:
          type: boolean
          description: A required question gates the save. These are the "starred"
            answers on the capture form.
          example: true
        choices:
          type: array
          description: Populated only for `choice`; an empty array otherwise, so a
            client never has to branch on nil-vs-empty.
          items:
            type: string
          example: []
        scale_min:
          type: integer
          nullable: true
          description: Present only for `scale`. Published because the bound is enforced
            in the model — a client that guessed 1..10 would build a picker whose
            top half always 422s.
          example: 1
        scale_max:
          type: integer
          nullable: true
          example: 5
        composite:
          type: boolean
          description: 'True when this question needs MORE THAN ONE control. `field_type`
            names only the FIRST one, so a client that renders `field_type` alone
            silently omits the rest — which shipped: the native capture form drew
            a recognition_pick''s person picker and not its citation, and because
            the recognition post body is built from that citation, every recognition
            posted from mobile carried no reason. A client never has to branch on
            this flag, because `inputs` is authoritative either way.'
          example: true
        inputs:
          type: array
          description: 'THE RENDER LIST — always present and always complete: one
            entry for a simple question, several for a composite. Render every entry
            and a composite cannot be half-built.'
          items:
            type: object
            required:
            - key
            - submit_as
            - field_type
            properties:
              key:
                type: string
                description: The submission key for this input, inside the question's
                  own hash. A simple question's is `value`.
                example: referenced_user_id
              submit_as:
                type: string
                enum:
                - answers
                - issues
                description: 'WHICH top-level hash this input posts under. Explicit
                  because it is the part most easily got wrong: an `issue_capture`''s
                  inputs go in `issues`, not `answers`, so a client that assumed one
                  envelope for everything would drop the whole issue.'
                example: answers
              field_type:
                type: string
                enum:
                - textarea
                - text
                - rating
                - radio
                - select
                - lookup
                - date
                example: lookup
              label:
                type: string
                example: Owner
              prompt:
                type: string
                description: The parent question's prompt, repeated for convenience.
              placeholder:
                type: string
                nullable: true
              help:
                type: string
                nullable: true
              required:
                type: boolean
                example: false
              lookup_source:
                type: string
                nullable: true
                description: For `lookup` inputs — the record set to search.
                example: users
              choices:
                type: array
                items:
                  type: string
                example:
                - low
                - medium
                - high
                - critical
              default:
                type: string
                nullable: true
                description: Server-side default when the input is left blank.
                example: medium
              max_length:
                type: integer
                nullable: true
                description: Published only where the server enforces it.
                example: 2000
    LeaderRoundsAnswer:
      type: object
      description: One answer on a round. `value` is the formatted display string;
        the typed columns ride alongside for clients that render the raw value (a
        scale drawn as filled pips rather than the text "3").
      required:
      - id
      - question_id
      - prompt
      - question_type
      - field_type
      - position
      properties:
        id:
          type: integer
          description: The ANSWER's id — what every write naming an answer takes,
            including `POST /leader-rounds/rounds/{id}/recognize`. DISTINCT from `question_id`,
            which identifies the template question this answers; sending that one
            instead is a lookup miss, not a match.
          example: 277
        question_id:
          type: integer
          example: 5
        prompt:
          type: string
          example: How supported do you feel in your role right now?
        question_type:
          type: string
          enum:
          - text
          - scale
          - boolean
          - choice
          - recognition_pick
          - issue_capture
        field_type:
          type: string
          enum:
          - textarea
          - rating
          - radio
          - select
          - lookup
        required:
          type: boolean
        position:
          type: integer
        value:
          type: string
          nullable: true
          description: Formatted for display — "Yes"/"No" for boolean, the name for
            a recognition pick.
          example: '5'
        value_text:
          type: string
          nullable: true
        value_number:
          type: number
          nullable: true
          description: The scale value.
        value_boolean:
          type: boolean
          nullable: true
        referenced_user_id:
          type: integer
          nullable: true
          description: For a `recognition_pick` — the colleague named.
        referenced_user_name:
          type: string
          nullable: true
          example: Amy Okonkwo
        recognition_posted:
          type: boolean
          description: True once the pick has been posted to the recipient's feed.
            Clients use this to show "posted" rather than offering the one-shot button
            again — a re-save must never spam the recipient.
          example: false
    LeaderRoundsIssue:
      type: object
      description: One row on the stoplight ledger — a `Capa::Action` sourced from
        a round. `stoplight` is DERIVED at read time, never stored.
      required:
      - id
      - description
      - status
      - stoplight
      properties:
        id:
          type: integer
          example: 614
        description:
          type: string
          example: Vitals machine in bay 3 still out of service
        status:
          type: string
          description: The status on the CAPA register.
          example: in_progress
        stoplight:
          type: string
          enum:
          - red
          - yellow
          - green
          description: 'Derived: green = completed, red = cancelled OR open-past-due,
            yellow = the open remainder. A CANCELLED issue is red but is a decision
            — label it "Won''t fix", not "Red".'
          example: yellow
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-08-31'
        assigned_to_id:
          type: integer
          nullable: true
        assigned_to_name:
          type: string
          nullable: true
          example: Dana Whitfield
        resolution_notes:
          type: string
          nullable: true
          description: Required when cancelling, and PUBLISHED to the person who raised
            the issue. This is the guardrail that keeps the ledger honest.
        round_id:
          type: integer
          description: The round this was raised in — tap-through target.
          example: 4
        pillar_id:
          type: integer
          nullable: true
          description: 'The strategic pillar this issue rolls up to. Resolved at read
            time: an explicit per-issue override wins, otherwise it is derived from
            the template question that raised the issue. `null` means Unaligned —
            either no override and an unmapped question, or an override deliberately
            pinning the issue to Unaligned. Settable via `PATCH /leader-rounds/issues/{id}`.'
          example: 67
        pillar_name:
          type: string
          nullable: true
          description: Display name of `pillar_id`, resolved for the caller so a client
            does not need a second lookup. `null` whenever `pillar_id` is null.
          example: People, Culture & Leadership
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          nullable: true
          description: As the leader set it on capture. Anything outside the enum
            is clamped to `medium` server-side, so a client never has to defend against
            a value it cannot render.
          example: high
        raised_by_name:
          type: string
          nullable: true
          description: The round's SUBJECT — the person who raised it. Deliberately
            not `created_by`, which RoundCreator sets to the round's LEADER unconditionally
            and would therefore name the same person on every row of that leader's
            own ledger. The web ledger links this same subject.
          example: Tasha Green
        created_by_name:
          type: string
          nullable: true
          description: 'WHO HANDED IT OVER — the round''s leader, who created the
            issue. A DIFFERENT person from `raised_by_name` (the round''s subject),
            and the one an assignee needs: nothing else in the payload says who gave
            them the work. Rendered as "Assigned by" on the subject screen.'
          example: Priya Raman
        round_occurred_on:
          type: string
          format: date
          nullable: true
          description: When the round took place, so a row can say WHEN something
            was raised rather than naming the raiser with no context.
          example: '2026-07-08'
        round_visible:
          type: boolean
          description: Whether the caller may OPEN the round this issue came from
            — a narrower question than whether they may read the issue, and not answerable
            from anything else in the payload. `GET /leader-rounds/issues/{id}` admits
            an issue merely ASSIGNED to the caller; `GET /leader-rounds/rounds/{id}`
            does not (an assignment grants no access to a round holding private notes
            and answers about a third party). A client must draw the link back to
            the round ONLY when this is true — otherwise it offers a route that always
            404s. Emitted on the single-issue read AND on both issue writes (PATCH
            `/issues/{id}` and `/issues/{id}/status`), because a detail screen folds
            a write reply back into the record it is displaying — a reply that omitted
            it made the link to the round disappear the moment a status was saved.
            No LIST payload carries it (a query per row).
          example: true
        read_only:
          type: boolean
          description: Whether the CALLER may move this issue. COMPUTED per caller
            against the write tier (the rounds they lead or supervise) — being the
            ASSIGNEE grants nothing. A client draws a status control only when this
            is false; when it is true the absence is the point, and the surface should
            say who does own the status. Emitted on the single-issue read, on both
            issue writes (same fold-back reason as `round_visible`), and on the assigned-to-me
            rows of /my.
          example: false
    LeaderRoundsIssueHistory:
      type: array
      description: |-
        The CAPA action's audit trail: the notes IssueStatusUpdater writes atomically with each status change, plus any progress note posted through `POST /leader-rounds/issues/{id}/comments`. So the dates are REAL transitions rather than a summary composed afterwards.
        AN ENVELOPE KEY, not a property of `issue` — carried by the single-issue read and by the status write, and by nothing else. The list endpoints omit it because it is a query per row, which is why a detail screen has to fetch the record rather than render the row it was handed. Top-level notes only (replies are nested under their parent and not returned here) and soft-deleted notes are excluded.
      items:
        type: object
        properties:
          id:
            type: integer
          author:
            type: string
            nullable: true
            description: Full name, or null if the author's user record is gone.
          author_avatar_url:
            type: string
            nullable: true
            description: Absolute URL, or null when the author has no photo (or no
              user record). `POST /leader-rounds/issues/{id}/comments` returns a `comment`
              in this SAME shape, so a client can append its reply straight into the
              trail rather than re-fetching.
          body:
            type: string
            example: 'Status changed: In progress → Completed — Replaced from spare
              pool.'
          created_at:
            type: string
            format: date-time
    LeaderRoundsScopeMetric:
      type: object
      description: One labelled number on a scope-tree node. DELIBERATELY GENERIC
        — the client component that renders the picker must not need to know what
        is being counted, so it can be reused by the next app that grows a drill-down.
        Labels are server-authored and already localized.
      required:
      - label
      - value
      - tone
      properties:
        label:
          type: string
          example: Red
        value:
          type: string
          description: Pre-formatted for display, so the client does not re-format.
          example: '3'
        tone:
          type: string
          enum:
          - neutral
          - warning
          - danger
          - success
          description: Drives emphasis only; never the sole carrier of meaning.
          example: danger
    LeaderRoundsScopeNode:
      type: object
      description: One row in the scope picker — a leader and their subtree's numbers.
      required:
      - id
      - name
      - leaf
      - metrics
      properties:
        id:
          type: integer
          example: 1222
        name:
          type: string
          example: Priya Raman
        leaf:
          type: boolean
          description: No layer below. A leaf row APPLIES immediately rather than
            drilling into an empty level.
          example: false
        metrics:
          type: array
          description: Aggregated over the node's WHOLE subtree, matching the rollup's
            unit rows.
          items:
            "$ref": "#/components/schemas/LeaderRoundsScopeMetric"
        quiet:
          type: boolean
          description: Nothing anywhere in this node's subtree. These rows are ordered
            LAST (never dropped — the picker is the only way to reach a node, and
            on the rollup a team that raises nothing is often the coverage gap worth
            opening), so a client may collapse them behind a "show all" that states
            how many it is hiding.
          example: false
    NotepadErrorBody:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
    NotepadValidationErrorBody:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              message:
                type: string
    NotepadCursorMeta:
      type: object
      description: Keyset pagination. `next_cursor` is null on the last page.
      properties:
        limit:
          type: integer
        has_more:
          type: boolean
        next_cursor:
          type: integer
          nullable: true
    NotepadListMeta:
      allOf:
      - "$ref": "#/components/schemas/NotepadCursorMeta"
      - type: object
        description: The meetings list's `meta`. ONE key set across both paging modes,
          with an explicit null where a key does not apply — so a client can declare
          a single type for the endpoint instead of treating every key as optional.
        properties:
          page:
            type: integer
            nullable: true
            description: Null in keyset mode, which has no page number.
          filters:
            type: object
            description: 'What the page was actually computed under. Values are the
              APPLIED ones, not the raw params: an omitted narrowing echoes null rather
              than claiming something that never happened. The cursor is a bare meeting
              id carrying no filter identity, so this is how a client detects a stale
              cursor under changed filters.'
            properties:
              view:
                type: string
                nullable: true
                description: "`trash` when Recently Deleted was asked for; null otherwise."
              sort:
                type: string
                nullable: true
                description: One of `edited` / `created` / `title`; null in keyset
                  mode.
              q:
                type: string
                nullable: true
              status:
                type: string
                nullable: true
              filter:
                type: string
                nullable: true
              notebook_id:
                type: integer
                nullable: true
          pagination:
            type: object
            description: Which paging mode answered, and what it cost the caller.
            properties:
              mode:
                type: string
                enum:
                - cursor
                - page
                description: "`page` when `sort` was sent, `cursor` otherwise. The
                  two are exclusive: a keyset cursor over a non-id ordering skips
                  and duplicates rows."
              ignored:
                type: array
                description: The paging params the caller SENT that this mode discarded.
                  Empty when the request named one mode cleanly.
                items:
                  type: string
                  enum:
                  - cursor
                  - page
    NotepadSectionName:
      type: string
      description: The SERVER section vocabulary. Note `insights`, not the client's
        `key_insights` tab name.
      enum:
      - summary
      - action_items
      - insights
    NotepadUserCompact:
      type: object
      description: Enough identity to render an avatar and a name.
      properties:
        id:
          type: integer
        name:
          type: string
        initials:
          type: string
        color:
          type: string
          description: Deterministic avatar colour.
        avatar_url:
          type: string
          nullable: true
    NotepadAssignee:
      type: object
      description: Like NotepadUserCompact but keyed `user_id` — this is an assignment,
        not a user record.
      properties:
        user_id:
          type: integer
        name:
          type: string
        initials:
          type: string
        color:
          type: string
        avatar_url:
          type: string
          nullable: true
    NotepadNotebookRef:
      type: object
      nullable: true
      description: Compact notebook reference embedded in a meeting. `name` and `title`
        are the SAME value — the notebook's `title` column — emitted twice on purpose
        so this embed and the `/notebooks` list overlap and a client can declare one
        Notebook type across both.
      properties:
        id:
          type: integer
        name:
          type: string
        title:
          type: string
          description: Alias of `name`; always identical.
    NotepadNotebook:
      type: object
      description: The `/notebooks` list row. Carries `name` alongside `title` for
        the same reason NotepadNotebookRef carries `title` — same value, both keys,
        so the two shapes of one entity overlap.
      properties:
        id:
          type: integer
        name:
          type: string
          description: Alias of `title`; always identical.
        title:
          type: string
        description:
          type: string
          nullable: true
        note_count:
          type: integer
        updated_at:
          type: string
          format: date-time
    NotepadMeetingRef:
      type: object
      description: Lightweight meeting reference for deep-linking.
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - processing
          - completed
          - failed
        date_label:
          type: string
        occurred_at:
          type: string
          format: date-time
          nullable: true
    NotepadMeetingSummary:
      type: object
      description: The list row. Carries denormalized counts and `artifacts_status`
        so a list render needs no per-row fan-out.
      properties:
        detail:
          type: boolean
          description: 'THE SHAPE DISCRIMINATOR, always present, so a typed client
            branches on it instead of probing for a key. `false` on this shape: the
            detail-only keys of NotepadMeetingDetail are ABSENT rather than null.'
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - processing
          - completed
          - failed
          description: Client vocabulary — see the file header for the mapping.
        source:
          type: string
          enum:
          - system_audio
          - voice_note
          - upload
          - paste
          - quick_note
          description: Client vocabulary — see the file header for the mapping.
        notebook_id:
          type: integer
          nullable: true
        notebook:
          "$ref": "#/components/schemas/NotepadNotebookRef"
        account:
          type: object
          nullable: true
          description: The Mango CS account the note is linked to, or null (ISS-20260910-524-C3B792).
            Set with `POST /ai_notepad/meetings/{id}/account`, cleared with the DELETE
            twin; pick from `GET /ai_notepad/meetings/linkable_accounts`.
          properties:
            id:
              type: integer
            name:
              type: string
        occurred_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        date_label:
          type: string
          description: Server-rendered human date, so surfaces cannot disagree.
        duration_sec:
          type: integer
          nullable: true
        excerpt:
          type: string
          nullable: true
          description: 'Short plain-text preview for the row, computed the same way
            the web list computes it (Apps::AiNotepadHelper#ai_notepad_note_excerpt):
            the opening block dropped when it merely repeats the title, tags stripped,
            truncated on a word boundary, and "Processing…" while the AI pipeline
            runs. Empty string when the note has no body.'
        has_ai_lifecycle:
          type: boolean
          description: False for a plain typed note — it has no processing state to
            show.
        pinned:
          type: boolean
        pinned_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
        action_items_open_count:
          type: integer
        action_items_total_count:
          type: integer
        artifacts_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        created_by:
          allOf:
          - "$ref": "#/components/schemas/NotepadUserCompact"
          nullable: true
    NotepadMeeting:
      allOf:
      - "$ref": "#/components/schemas/NotepadMeetingSummary"
      description: The shape write endpoints echo back. Same fields as the list row.
    NotepadMeetingEntity:
      allOf:
      - "$ref": "#/components/schemas/NotepadMeetingSummary"
      - type: object
        description: 'The SERIALIZER''s full meeting shape — the list row plus the
          detail-only keys. Signed/expiring media URLs and the share-link state are
          NOT here: the serializer stays request-agnostic and the detail controller
          merges those on top (see NotepadMeetingDetail).'
        properties:
          detail:
            type: boolean
            description: Always `true` on this shape. See NotepadMeetingSummary.
          language:
            type: string
            nullable: true
          error_message:
            type: string
            nullable: true
            description: Present only when `status` is `failed`.
          notes:
            type: string
            nullable: true
            description: Server-derived markdown twin of `notes_html`. Null for a
              recording-backed meeting.
          notes_html:
            type: string
            nullable: true
            description: The canonical rich-text body.
          can_edit:
            type: boolean
            description: Whether THIS caller may edit the content (owner/editor),
              so the client can gate its edit affordances instead of showing an Edit
              button that 403s.
          can_manage_sharing:
            type: boolean
            description: Whether this caller may open the Share dialog. A DIFFERENT
              right from `can_edit` — the members endpoints 403 a viewer — and the
              same predicate the web view gates on, so a client never offers a door
              the server refuses.
          summary_available:
            type: boolean
          insights_available:
            type: boolean
          has_audio_overview:
            type: boolean
          has_recording:
            type: boolean
          has_transcript:
            type: boolean
          speakers:
            type: array
            description: The speaker roster. Every element carries an `id` (`s1`,
              `s2`, …) that a transcript segment's `speaker` resolves against — derived
              from the element's position when the stored roster has none.
            items:
              type: object
              additionalProperties: true
    NotepadMeetingDetail:
      allOf:
      - "$ref": "#/components/schemas/NotepadMeetingEntity"
      - type: object
        description: 'What `GET /meetings/{id}` answers: the serializer''s entity
          plus the request-specific affordances it deliberately leaves out.'
        properties:
          recording:
            type: object
            nullable: true
            description: Signed, expiring — refetch rather than storing it.
            properties:
              url:
                type: string
                nullable: true
              expires_in:
                type: integer
                nullable: true
              content_type:
                type: string
                nullable: true
              byte_size:
                type: integer
                nullable: true
          audio_overview:
            "$ref": "#/components/schemas/NotepadAudioOverview"
          document:
            type: object
            nullable: true
            description: The source document, for opening externally.
            properties:
              web_url:
                type: string
                nullable: true
              updated_at:
                type: string
                format: date-time
                nullable: true
          share_link:
            allOf:
            - "$ref": "#/components/schemas/NotepadShareLink"
            nullable: true
            description: Always present; null until a public link exists. Public share
              links are not yet implemented, so this is currently always null.
          meeting:
            allOf:
            - "$ref": "#/components/schemas/NotepadMeetingEntity"
            description: 'The identical serialized entity, repeated under the key
              every OTHER meeting-returning action uses (`{ meeting: … }`), so one
              "render a meeting" client path works against all of them. The bare root
              keys are the shipped contract and stay; this is the forward one. The
              request-specific extras above (`recording`, `audio_overview`, `document`,
              `share_link`) are deliberately NOT folded into it.'
    NotepadMutationAck:
      type: object
      description: |-
        ONE MERGEABLE SHAPE FOR THE FOUR TERSE ACKS — delete, restore, pin and unpin. Every one of them answers all five keys, so a client merges any of them into its cached row with a single code path and re-renders correctly. (This replaces the old NotepadPinState, which documented only `id`/`pinned`/`pinned_at`, and the delete/restore shapes, which documented only `id`/`deleted_at` and the wrong entity respectively.)
        Both pairs matter on every ack: `pinned_at` is the column every viewer's list is ordered by, so a restore that did not report it left the client unable to place the note it just brought back; and `deleted`/`deleted_at` on a pin ack is what tells a client the row it is re-sorting is still live.
      properties:
        id:
          type: integer
        deleted:
          type: boolean
        deleted_at:
          type: string
          format: date-time
          nullable: true
          description: Non-null only while the note is in Recently Deleted; the client
            counts the 30-day purge window down from it.
        pinned:
          type: boolean
        pinned_at:
          type: string
          format: date-time
          nullable: true
    NotepadCreateResponse:
      type: object
      description: ONE BODY SHAPE FOR EVERY OUTCOME OF the create. The action has
        three exits — the 201 that made a note, and the two 200s that hand back a
        note that already existed for the same calendar occurrence — and all three
        answer this, so a client merges any of them into one cache.
      properties:
        meeting:
          "$ref": "#/components/schemas/NotepadMeeting"
        processing:
          type: object
          description: Whether the AI pipeline was actually enqueued, so a client
            can stop rendering a progress state for a job that was never started.
            `awaiting_media` is the OPPOSITE of `nothing_to_process` — an upload meeting
            is created with no media on purpose and the recording enqueues the job
            on its own request, so the progress affordance should stay up.
          properties:
            started:
              type: boolean
              description: True only when `reason` is `queued`.
            reason:
              type: string
              enum:
              - queued
              - ai_disabled
              - auto_process_disabled
              - nothing_to_process
              - awaiting_media
              - already_exists
        truncated_fields:
          type: array
          description: Which body fields the server's length ceiling cut. Always an
            array so the key set is stable; empty is the normal case.
          items:
            type: string
        calendar:
          type: object
          nullable: true
          description: The calendar identity of the note being handed back, or null
            when it carries none. The dedup lookup deliberately keys on the event
            id and NOT the provider, so a create for one provider can legitimately
            return the note stored under another — `provider_mismatch` is how the
            client learns that rather than mislabelling the note.
          properties:
            event_id:
              type: string
            provider:
              type: string
              nullable: true
            requested_provider:
              type: string
              nullable: true
            provider_mismatch:
              type: boolean
    NotepadArtifacts:
      type: object
      description: The AI output for a meeting.
      properties:
        meeting_id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - ready
          - failed
          description: The ARTIFACTS vocabulary — `pending` means show a skeleton,
            not an error. Note this key carries the MEETING vocabulary on the sibling
            transcript and action-items reads, and the two COLLIDE on `failed`; `artifacts_status`
            below is the unambiguous name.
        artifacts_status:
          type: string
          enum:
          - pending
          - ready
          - failed
          description: The same value as `status`, restated under the name all three
            of this meeting's reads share.
        meeting_status:
          type: string
          enum:
          - processing
          - completed
          - failed
          description: The meeting's own lifecycle, unambiguously named.
        summary:
          type: string
          nullable: true
        meeting_type:
          type: string
          nullable: true
        sections:
          type: array
          description: Ordered insight sections, as rendered.
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              items:
                type: array
                items:
                  type: string
        decisions:
          type: array
          items:
            type: string
        risks:
          type: array
          items:
            type: string
        themes:
          type: array
          items:
            type: string
        error_message:
          type: string
          nullable: true
        meta:
          type: object
          description: What the four collections above hold, and what was capped off
            them — the only read in the namespace that returned collections with no
            count and no bound.
          properties:
            sections_count:
              type: integer
              description: Sections BEFORE the cap, so a client sees what it is missing.
            items_count:
              type: integer
            decisions_count:
              type: integer
            risks_count:
              type: integer
            themes_count:
              type: integer
            max_sections:
              type: integer
            max_items:
              type: integer
            has_more:
              type: boolean
              description: True when any cap bit. There is no cursor — refetch is
                the only page.
    NotepadSectionSaveResponse:
      type: object
      description: Returned by every section write, so the client can re-render without
        a refetch.
      properties:
        meeting_id:
          type: integer
        section:
          "$ref": "#/components/schemas/NotepadSectionName"
        artifacts:
          "$ref": "#/components/schemas/NotepadArtifacts"
        action_items:
          type: array
          description: Present only for the `action_items` section.
          items:
            "$ref": "#/components/schemas/NotepadActionItem"
        saved_at:
          type: string
          format: date-time
    NotepadSectionVersion:
      type: object
      properties:
        id:
          type: integer
        section:
          "$ref": "#/components/schemas/NotepadSectionName"
        created_at:
          type: string
          format: date-time
        author:
          "$ref": "#/components/schemas/NotepadUserCompact"
        preview:
          type: string
          nullable: true
          description: Truncated content, for choosing a version to restore.
    NotepadTranscriptSegment:
      type: object
      properties:
        id:
          type: integer
        seq:
          type: integer
          description: 1-based ordinal; also the pagination cursor.
        t:
          type: string
          nullable: true
          description: Human time label.
        start_ms:
          type: integer
          nullable: true
          description: Currently always null — segments are derived from the stored
            transcript at request time, with no per-segment timing.
        end_ms:
          type: integer
          nullable: true
          description: Currently always null.
        speaker:
          type: string
          nullable: true
        text:
          type: string
        is_final:
          type: boolean
        confidence:
          type: number
          nullable: true
          description: Currently always null.
        highlights:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true
    NotepadActionItem:
      type: object
      properties:
        id:
          type: integer
        meeting_id:
          type: integer
        title:
          type: string
        done:
          type: boolean
          description: The client view of `status`.
        status:
          type: string
          enum:
          - pending
          - completed
        commitment_type:
          type: string
          enum:
          - hard
          - soft
        due_date:
          type: string
          format: date
          nullable: true
        due_label:
          type: string
          nullable: true
          description: Server-rendered relative due label ("Overdue", "Tomorrow").
        completed_at:
          type: string
          format: date-time
          nullable: true
        assignee:
          allOf:
          - "$ref": "#/components/schemas/NotepadAssignee"
          nullable: true
        created_at:
          type: string
          format: date-time
    NotepadMember:
      type: object
      description: A person a meeting is shared with. The creator appears as an immutable
        synthetic `owner`.
      properties:
        id:
          type: integer
        user_id:
          type: integer
        name:
          type: string
        email:
          type: string
          nullable: true
        initials:
          type: string
        color:
          type: string
        avatar_url:
          type: string
          nullable: true
        role:
          type: string
          enum:
          - owner
          - editor
          - viewer
        is_owner:
          type: boolean
        shared_at:
          type: string
          format: date-time
          nullable: true
    NotepadAudioCitation:
      type: object
      description: A citation on the narrated audio overview — it labels a SECTION
        of the narration script. A DIFFERENT entity from NotepadChatCitation, despite
        riding under the same `citations` field name; discriminate on the endpoint
        you called, never on which keys happen to exist. Every key is always present,
        with an explicit null when the value is missing.
      properties:
        index:
          type: integer
          nullable: true
          description: 0-based section ordinal.
        label:
          type: string
          nullable: true
        source_hint:
          type: string
          nullable: true
    NotepadAudioOverview:
      type: object
      description: Audio-overview state — the whole shape, identical wherever it appears
        (embedded on the meeting detail, and at the root of the dedicated read).
      properties:
        available:
          type: boolean
        failed:
          type: boolean
          description: 'The generation terminally FAILED. A third state the shape
            used to lack, which left a client that POSTed and then polled seeing `available:
            false` forever on a run that would never finish.'
        state:
          type: string
          enum:
          - ready
          - failed
          - none
          description: 'THE ONE STATE FIELD, so a poll has something to poll on —
            the same key and vocabulary the POST answers with (`generating` there).
            There is deliberately no `generating` value on a read: nothing on the
            meeting records that a run is in flight, and inventing one from the job
            queue would make a read depend on GoodJob''s retention. A client polls
            `none` until it becomes `ready` or `failed`.'
        error:
          type: string
          nullable: true
          description: The failure reason. Non-null only when `failed` is true.
        url:
          type: string
          nullable: true
          description: Signed and expiring — refetch rather than storing it.
        expires_in:
          type: integer
          nullable: true
          description: Seconds.
        script:
          type: string
          nullable: true
          description: The narration script, for captions drawn while the MP3 plays.
            Truncated to the same ceiling the synthesizer used, so the captions cannot
            outrun the audio.
        citations:
          type: array
          items:
            "$ref": "#/components/schemas/NotepadAudioCitation"
    NotepadAudioOverviewDetail:
      allOf:
      - "$ref": "#/components/schemas/NotepadAudioOverview"
      - type: object
        properties:
          meeting_id:
            type: integer
          audio_overview:
            allOf:
            - "$ref": "#/components/schemas/NotepadAudioOverview"
            description: The identical object, repeated under the key `meetings#show`
              nests it at. ONE ENTITY, ONE ACCESSOR — this was the only entity read
              in the namespace served at the top level, so a client needed two accessors
              for one object depending on which endpoint it had called. The root keys
              are the shipped contract and stay.
    NotepadChatCitation:
      type: object
      description: A citation on a grounded chat answer — it points at a SOURCE MEETING,
        not into the transcript. `index` is the same N the inline `[Source N]` marker
        in the answer text carries, renumbered so the first cited source is 1. Citations
        are grounded in retrieved content chunks rather than transcript segments,
        so there is no per-segment time label; `position` is the chunk's ordinal within
        its meeting. Every key is always present, with an explicit null when the value
        is missing.
      properties:
        index:
          type: integer
          nullable: true
        meeting_id:
          type: integer
          nullable: true
        meeting_title:
          type: string
          nullable: true
        excerpt:
          type: string
          nullable: true
          description: Up to 200 characters.
        position:
          type: integer
          nullable: true
    NotepadChatMessage:
      type: object
      properties:
        id:
          type: integer
        role:
          type: string
          enum:
          - user
          - assistant
        content:
          type: string
        created_at:
          type: string
          format: date-time
        citations:
          type: array
          items:
            "$ref": "#/components/schemas/NotepadChatCitation"
    NotepadUpcomingMeeting:
      type: object
      description: 'A calendar event starting soon, for the pre-meeting prompt. NOT
        a row of `GET /meetings` even though both live under a `meetings` key: the
        two share exactly one field name (`title`), this one keys the note as `meeting_id`
        where the list uses `id`, and everything else is calendar data the list has
        never carried. `kind` is the tag that tells them apart.'
      properties:
        kind:
          type: string
          enum:
          - upcoming_meeting
          description: The per-element discriminator against a meetings-list row.
        calendar_event_id:
          type: string
          nullable: true
        calendar_provider:
          type: string
          nullable: true
          description: Send this and `calendar_event_id` back on `POST /meetings`
            so the note the desktop makes and the note the web makes for one occurrence
            are the same row.
        title:
          type: string
        starts_at:
          type: string
          format: date-time
          nullable: true
        ends_at:
          type: string
          format: date-time
          nullable: true
        starts_in_seconds:
          type: integer
        join_url:
          type: string
          nullable: true
        location:
          type: string
          nullable: true
        meeting_id:
          type: integer
          nullable: true
          description: The notepad note that already exists for this occurrence, or
            null — the client opens the existing note rather than starting a second
            one.
    NotepadShareLink:
      type: object
      description: A public, tokenised link to a read-only snapshot. NOT yet implemented
        — the field exists so clients bind against a stable shape, and is currently
        always null on the meeting detail.
      properties:
        token:
          type: string
        url:
          type: string
        scope:
          type: string
          enum:
          - summary
          - full
        expires_at:
          type: string
          format: date-time
          nullable: true
        revoked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    NotepadPreferences:
      type: object
      description: EFFECTIVE preferences — the user's stored values resolved against
        the tenant policy.
      properties:
        meeting_detection_enabled:
          type: boolean
        auto_capture:
          type: string
        default_notebook_id:
          type: integer
          nullable: true
        default_language:
          type: string
          nullable: true
        retain_audio_days:
          type: integer
        notify_on_ready:
          type: boolean
        default_template_id:
          type: integer
          nullable: true
        capture_consent_granted:
          type: boolean
        consent_notice_required:
          type: boolean
        meeting_reminders_enabled:
          type: boolean
        meeting_reminder_lead_minutes:
          type: integer
        user_id:
          type: string
        business_id:
          type: string
        policy:
          type: object
          description: The admin-set ceiling these preferences resolve against.
          properties:
            capture_enabled:
              type: boolean
            max_retain_audio_days:
              type: integer
            consent_notice_text:
              type: string
              nullable: true
            admin_owned:
              type: array
              description: Keys a user cannot override.
              items:
                type: string
    AlertUser:
      type: object
      description: Basic sender details (canonical v1 user shape — id / name / avatar_url).
      nullable: true
      required:
      - id
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
    AlertSummary:
      type: object
      description: |
        Alert shape returned by the list endpoint. `acknowledged` /
        `acknowledged_at` / `checkin_status` / `checked_in_at` / `response_pending`
        reflect the CALLER's own response state; `author` is the sender.
      required:
      - id
      - title
      - body
      - alert_type
      - status
      - urgent
      - ack_required
      - safety_check_in_required
      - channels
      - recipients_count
      - author
      - acknowledged
      - response_pending
      - created_at
      properties:
        id:
          type: integer
        title:
          type: string
        body:
          type: string
        alert_type:
          type: string
          description: Display type derived from the flags (response-first).
          enum:
          - urgent
          - acknowledge
          - safety_check_in
        status:
          type: string
          example: sent
        urgent:
          type: boolean
        ack_required:
          type: boolean
        safety_check_in_required:
          type: boolean
        channels:
          type: array
          items:
            type: string
        recipients_count:
          type: integer
        author:
          "$ref": "#/components/schemas/AlertUser"
        acknowledged:
          type: boolean
          description: Whether the calling user has acknowledged this alert.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        checkin_status:
          type: string
          nullable: true
          enum:
          - safe
          - needs_help
          -
          description: The calling user's safety check-in status, if any.
        checked_in_at:
          type: string
          format: date-time
          nullable: true
        checkin_message:
          type: string
          nullable: true
          description: |
            The message the caller entered in the check-in flow (the `needs_help`
            note; null when they marked safe or haven't checked in). Returned by
            GET /alerts/{id} only.
        response_pending:
          type: boolean
          description: True when the alert needs a response the caller hasn't given
            yet.
        sent_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        approval_id:
          type: integer
          nullable: true
          description: |
            The Comms Hub approval request id. Present on `pending_approvals`
            items (pass it to POST /approvals/{id}/approve|reject), on
            `?filter=draft` items that have a request (the latest one — null for a
            plain draft), and on GET /alerts/{id}. Omitted from the other `alerts`
            filters.
        approval_state:
          type: string
          nullable: true
          enum:
          - draft
          - pending_approval
          - approved
          - rejected
          description: |
            The alert's position in the approval pipeline, from its latest Comms
            Hub approval request: `draft` (never submitted / withdrawn),
            `pending_approval` (awaiting a reviewer), `approved` (cleared, pending
            the author's send), or `rejected`. Present on `?filter=draft` items
            and on GET /alerts/{id} for a DRAFT alert. On the detail view it is
            `null` once the alert is published, for a draft that never entered the
            approval pipeline, and for a caller who may not see the alert's
            internal review trail (non-author, non-privileged).
        approval_decided_by:
          type: string
          nullable: true
          description: |
            The NAME of the reviewer who approved or rejected the alert. On
            `?filter=draft` items and GET /alerts/{id}. Populated only when
            `approval_state` is `approved` or `rejected`; null otherwise (and null
            on the detail view for callers who can't see the review trail).
        approval_decided_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When the alert was approved/rejected (ISO-8601). On `?filter=draft`
            items and GET /alerts/{id}. Null unless `approval_state` is
            `approved`/`rejected`.
        approval_notes:
          type: string
          nullable: true
          description: |
            The reviewer's decision notes for the approval/rejection. On
            `?filter=draft` items and GET /alerts/{id}. Null unless
            `approval_state` is `approved`/`rejected` (and null when the reviewer
            left no note, or for a detail-view caller who can't see the trail).
    AlertInput:
      type: object
      description: Writable alert attributes (create).
      required:
      - title
      - body
      properties:
        title:
          type: string
        body:
          type: string
        sms_body:
          type: string
        action_url:
          type: string
        urgent:
          type: boolean
        ack_required:
          type: boolean
        safety_check_in_required:
          type: boolean
        audience_id:
          type: integer
        channels:
          type: array
          items:
            type: string
    AlertTemplate:
      type: object
      description: |
        A reusable alert composition offered in the composer's template picker.
        `your_templates` items are this business's saved templates;
        `common_scenarios` items are platform-curated system templates
        (`system_template: true`). The composition fields pre-fill the new-alert
        form; the saved default audience lives in `metadata` (empty for system
        scenarios).
      required:
      - id
      - name
      - system_template
      - body
      - channels
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        system_template:
          type: boolean
          description: True for platform-curated common scenarios (business_id is
            null).
        title:
          type: string
          nullable: true
        body:
          type: string
        sms_body:
          type: string
          nullable: true
        channels:
          type: array
          items:
            type: string
        urgent:
          type: boolean
        ack_required:
          type: boolean
        safety_check_in_required:
          type: boolean
        metadata:
          type: object
          description: |
            Raw targeting blob the composer hydrates the new-alert form from —
            holds `extra_user_ids`, `audience_criteria`, and
            `recipient_group_ids`. Empty object for system scenarios.
          additionalProperties: true
        audience:
          type: object
          nullable: true
          description: |
            Resolved delivery-target ("Audience") breakdown — present on the show
            endpoint and on `pending_approvals` / `?filter=draft` items (omitted
            from the paginated received list to stay N+1-free). Every selected
            entity is resolved to `{ id, name }` so the client renders
            human-readable labels instead of bare ids.
          properties:
            id:
              type: integer
              nullable: true
              description: Bound CommsHub audience id (null when none).
            name:
              type: string
              nullable: true
            summary:
              type: string
              nullable: true
            recipient_groups:
              type: array
              description: Selected notification recipient groups, each as { id, name
                }.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                    nullable: true
            specific_user_ids:
              type: array
              description: Ad-hoc specific recipients, each resolved to { id, name
                }.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                    nullable: true
            audience_criteria:
              type: array
              description: |
                Attribute-based targeting, one entry per criterion. Each carries
                its `type` and an `items` array of `{ id, name }`: id-based types
                (`location`, `department`) resolve to the record's name; value-based
                types (`role`, `job_title`) echo the value as both id and name;
                `everyone` (and any unknown type) carries an empty `items`.
              items:
                type: object
                properties:
                  type:
                    type: string
                    enum:
                    - location
                    - department
                    - role
                    - job_title
                    - everyone
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          oneOf:
                          - type: integer
                          - type: string
                        name:
                          type: string
                          nullable: true
    AlertListMeta:
      type: object
      description: |
        Pagination + "needs my response" counts for the list endpoint, spanning
        the whole received inbox (independent of the active `filter`):
          * acknowledge     — ack-required alerts the caller has NOT acknowledged
          * safety_check_in — safety-check-in alerts the caller has NOT responded to
          * all             — the sum of the two (total responses the caller owes)
        No `urgent` count — the accountability invariant makes every alert urgent,
        so it would just equal `all`.
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      - segment_counts
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        segment_counts:
          type: object
          required:
          - all
          - acknowledge
          - safety_check_in
          - draft
          properties:
            all:
              type: integer
            acknowledge:
              type: integer
            safety_check_in:
              type: integer
            draft:
              type: integer
              description: 'The caller''s OWN unsent alerts (authored + status ''draft'')
                — the count for the ?filter=draft tab. Author-scoped, unlike the received-inbox
                counts above.

                '
    ApprovalUser:
      type: object
      description: Canonical v1 user shape (requester) — id / name / avatar_url.
      nullable: true
      required:
      - id
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
    Approval:
      type: object
      description: |
        Approval context block — identical to the `approval` block on each
        source's index `pending_approvals` item, and returned (post-decision) by
        the decision endpoints. `can_act` is the caller's live ability to act at
        the current step.
      required:
      - request_id
      - status
      - requested_at
      - current_step
      - can_act
      properties:
        request_id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - withdrawn
        requested_by:
          "$ref": "#/components/schemas/ApprovalUser"
        requested_at:
          type: string
          format: date-time
        current_step:
          type: object
          required:
          - index
          - total
          properties:
            index:
              type: integer
              description: Zero-based index of the current step.
            total:
              type: integer
              description: Total number of steps in the workflow.
            role:
              type: string
              nullable: true
              description: The UserBusiness role that can act on this step.
            label:
              type: string
              nullable: true
              description: Human label for this step.
        can_act:
          type: boolean
    ApprovalDecision:
      type: object
      description: Response from POST /approvals/{id}/approve and /reject.
      required:
      - source_type
      - source_id
      - approval
      properties:
        source_type:
          type: string
          description: The approved/rejected item's source type.
          enum:
          - Broadcast
          - Alert
        source_id:
          type: integer
          description: The source record's id (broadcast or alert).
        approval:
          allOf:
          - "$ref": "#/components/schemas/Approval"
          description: |
            The approval state AFTER the decision — `status` reflects the new
            state (`approved` / `rejected`, or still `pending` if the workflow
            advanced to a later step) and `can_act` is `false`.
    MessageResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        conversation_id:
          type: string
          format: uuid
          description: Unique identifier for this conversation
          example: 550e8400-e29b-41d4-a716-446655440000
        status:
          type: string
          enum:
          - processing
          - completed
          - error
          example: processing
        websocket:
          type: object
          description: WebSocket subscription details
          properties:
            channel:
              type: string
              example: AiResponseChannel
            subscription:
              type: object
              properties:
                conversation_id:
                  type: string
                  format: uuid
    Conversation:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Conversation session ID
        type:
          type: string
          enum:
          - forms
          - tasks
          - epms
          - general
          example: general
        status:
          type: string
          enum:
          - active
          - completed
          - archived
          example: active
        message_count:
          type: integer
          example: 12
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
    Message:
      type: object
      properties:
        role:
          type: string
          enum:
          - user
          - assistant
          - system
          example: assistant
        content:
          type: string
          example: You have 15 PTO days remaining for this year.
        timestamp:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
          description: Additional message metadata (intent, tool usage, etc.)
    Pagination:
      type: object
      properties:
        total_count:
          type: integer
          example: 25
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total_pages:
          type: integer
          example: 2
        has_more:
          type: boolean
          example: true
    AgentInfo:
      type: object
      properties:
        name:
          type: string
          example: Scheduling Assistant
        slug:
          type: string
          example: scheduling
        description:
          type: string
          example: Helps with shifts, schedules, and time off requests
        icon:
          type: string
          example: calendar
        examples:
          type: array
          items:
            type: string
          example:
          - What shifts do I have this week?
          - Request time off for next Friday
          - Who is working tomorrow?
    WebSocketEvent:
      type: object
      description: |
        Events broadcast via WebSocket on AiResponseChannel.
        Subscribe with { channel: "AiResponseChannel", conversation_id: "uuid" }
      properties:
        type:
          type: string
          enum:
          - chunk
          - complete
          - error
          - status
          - cancelled
          description: |
            - chunk: Streaming text fragment
            - complete: Full response with metadata
            - error: Error occurred
            - status: Status update (thinking, generating)
            - cancelled: Request was cancelled
        content:
          type: string
          description: Text content (for chunk and complete types)
        message:
          type: string
          description: Status or error message
        metadata:
          type: object
          description: Additional data (for complete type)
          properties:
            conversation_id:
              type: string
            completed_at:
              type: string
              format: date-time
            tokens_generated:
              type: integer
    AskAiSettings:
      type: object
      description: Feature settings for the mobile AI client, including current conversation
        for session restoration
      properties:
        success:
          type: boolean
          example: true
        settings:
          type: object
          properties:
            create_support_ticket_enabled:
              type: boolean
              description: Whether "Create Support Ticket" button should be shown
              example: true
            function_calls_enabled:
              type: boolean
              description: Whether AI can execute actions on behalf of the user
              example: true
            schedule_queries_enabled:
              type: boolean
              description: Whether scheduling-related queries are available
              example: true
            general_questions_enabled:
              type: boolean
              description: Whether general Q&A is enabled
              example: true
            clear_conversation_enabled:
              type: boolean
              description: |
                Whether the admin allows users to clear their conversation. Check this
                before showing a "Clear conversation" / "Reset AI" control —
                `DELETE /ask_ai/conversations/{id}` returns 403
                `clear_conversation_disabled` when it is false.
              example: true
            voice_mode_enabled:
              type: boolean
              description: Whether voice mode is available for this business. Check
                this before showing voice button.
              example: true
            voice_mode_limit_info:
              type: object
              nullable: true
              description: Voice mode usage limits (only present if voice_mode_enabled
                is true)
              properties:
                daily_used:
                  type: integer
                  description: Minutes used today
                  example: 5
                daily_limit:
                  type: integer
                  description: Daily limit in minutes
                  example: 30
                remaining:
                  type: integer
                  description: Remaining minutes for today
                  example: 25
                session_limit:
                  type: integer
                  description: Max minutes per session
                  example: 15
        current_conversation:
          nullable: true
          description: |
            The user's current/latest active conversation for session restoration.
            Returns null if no active conversation exists.
            Use this to restore conversation history after logout/login.
          allOf:
          - "$ref": "#/components/schemas/Conversation"
    VoiceErrorResponse:
      type: object
      description: Error response for voice mode requests
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
          description: Error message
          example: Voice mode daily limit reached
        limit_info:
          type: object
          description: Current usage limits (if applicable)
          properties:
            daily_used:
              type: integer
            daily_limit:
              type: integer
            remaining:
              type: integer
        billing_info:
          type: object
          description: Billing information (if applicable)
          properties:
            balance:
              type: integer
            resource_type:
              type: string
              example: voice_minutes
    VoiceRealtimeChannelEvent:
      type: object
      description: |
        Events broadcast via ActionCable on VoiceRealtimeChannel.
        Subscribe with { channel: "VoiceRealtimeChannel", session_id: "uuid" }

        **Sending Actions:**
        - `process_query`: Send transcript for backend processing
        - `end_session`: Gracefully end the voice session

        **Receiving Events:**
        - `query_response`: Backend AI response ready
        - `query_error`: Query processing failed
        - `session_ended`: Session finalized with billing info
      properties:
        type:
          type: string
          enum:
          - query_response
          - query_error
          - session_ended
          description: Event type
        text:
          type: string
          description: AI response text (for query_response)
        success:
          type: boolean
          description: Whether the query was successful
        error:
          type: string
          description: Error message (for query_error)
        metadata:
          type: object
          description: Additional response metadata
          properties:
            intent:
              type: string
            confidence:
              type: number
        duration_minutes:
          type: number
          description: Final session duration (for session_ended)
        cost_cents:
          type: integer
          description: Final session cost (for session_ended)
        billed:
          type: boolean
          description: Whether billing was processed (for session_ended)
    BroadcastUser:
      type: object
      description: Basic sender details (canonical v1 user shape — id / name / avatar_url).
      nullable: true
      required:
      - id
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
    BroadcastSummary:
      type: object
      description: |
        Broadcast shape returned by the list endpoint. `viewed` / `acknowledged`
        reflect the CALLER's own state for this broadcast; `created_by` is the
        sender.
      required:
      - id
      - title
      - description
      - status
      - is_critical
      - require_acknowledgment
      - viewed
      - acknowledged
      - created_by
      - created_at
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
        status:
          type: string
          example: published
        is_critical:
          type: boolean
        require_acknowledgment:
          type: boolean
        scheduled_at:
          type: string
          format: date-time
          nullable: true
          description: When a "send later" broadcast will go out. Non-null only while
            `status` is `scheduled`.
        delivery_mode:
          type: string
          enum:
          - immediate
          - on_shift_only
          - next_shift_start
          description: The shift-aware delivery window this broadcast was composed
            with. Always present; `immediate` when none was chosen.
        auto_widen_channels:
          type: boolean
          description: Whether each unacknowledged reminder widens the delivery channel
            (email -> +SMS -> +voice). Always present; `false` when off.
        publish_to_signage:
          type: boolean
          description: Whether this broadcast is marked for the break-room screen
            rotation. Always present; `false` when off. A marking, not a delivery
            receipt — each screen still shows it only if its own Communications content
            rule is on, and only while the broadcast is published.
        signage_location_ids:
          type: array
          description: Sites the screen marking is narrowed to. Empty means every
            screen (and is always empty when `publish_to_signage` is false).
          items:
            type: integer
        viewed:
          type: boolean
          description: Whether the calling user has viewed this broadcast.
        acknowledged:
          type: boolean
          description: Whether the calling user has acknowledged this broadcast.
        created_by:
          "$ref": "#/components/schemas/BroadcastUser"
        recipients_count:
          type: integer
          description: |
            Projected recipient count (resolved audience). Present on
            `pending_approvals` items so an approver sees the reach; omitted from
            the main `broadcasts` list.
        approval_id:
          type: integer
          description: |
            The Comms Hub approval request id — present ONLY on
            `pending_approvals` items; pass it to POST /approvals/{id}/approve|
            reject. Omitted from the main `broadcasts` list.
        published_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    BroadcastDetail:
      type: object
      description: Full broadcast shape returned by show / create / update / publish.
      required:
      - id
      - title
      - status
      - is_critical
      - created_at
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
        is_critical:
          type: boolean
        published_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        description:
          type: string
        require_acknowledgment:
          type: boolean
        scheduled_at:
          type: string
          format: date-time
          nullable: true
          description: When a "send later" broadcast will go out. Non-null only while
            `status` is `scheduled`.
        delivery_mode:
          type: string
          enum:
          - immediate
          - on_shift_only
          - next_shift_start
          description: The shift-aware delivery window this broadcast was composed
            with. Always present; `immediate` when none was chosen.
        auto_widen_channels:
          type: boolean
          description: Whether each unacknowledged reminder widens the delivery channel
            (email -> +SMS -> +voice). Always present; `false` when off.
        publish_to_signage:
          type: boolean
          description: Whether this broadcast is marked for the break-room screen
            rotation. Always present; `false` when off. A marking, not a delivery
            receipt — each screen still shows it only if its own Communications content
            rule is on, and only while the broadcast is published.
        signage_location_ids:
          type: array
          description: Sites the screen marking is narrowed to. Empty means every
            screen (and is always empty when `publish_to_signage` is false).
          items:
            type: integer
        viewed:
          type: boolean
          description: Whether the calling user has viewed this broadcast.
        acknowledged:
          type: boolean
          description: Whether the calling user has acknowledged this broadcast. On
            GET /broadcasts/{id} this is always true when the caller AUTHORED the
            broadcast and is not in its audience — there is nothing for the author
            to acknowledge; otherwise it reflects the caller's own acknowledgment
            (an author who IS a recipient must acknowledge like anyone else).
        created_by:
          "$ref": "#/components/schemas/BroadcastUser"
        can_manage:
          type: boolean
          description: Whether the caller can manage this broadcast (resend / remind
            / archive / edit / stats).
        can_view_tracking:
          type: boolean
          description: 'Whether the caller may open the per-recipient tracking details
            (viewers / acknowledgement response-tracking roster) — the mobile equivalent
            of the web "View stats" page. Matches the gate on GET /broadcasts/{id}/acknowledgements
            (can-manage, not published-gated), so the client shows the affordance
            only when that call will succeed. Returned by GET /broadcasts/{id} only.

            '
        view_count:
          type: integer
          description: |
            Raw view tally (column). Returned by the compose responses
            (create / update / publish) only — NOT by GET /broadcasts/{id},
            whose detail screen uses `unique_view_count` instead.
        created_by_id:
          type: integer
          description: |
            Author id. Returned by the compose responses only — NOT by
            GET /broadcasts/{id} (the author id is already in `created_by.id`).
        attachments:
          type: array
          description: Broadcast file attachments (DriveItem media).
          items:
            "$ref": "#/components/schemas/BroadcastAttachment"
        reactions:
          type: array
          description: Reaction tallies on this broadcast, one entry per emoji.
          items:
            type: object
            required:
            - emoji
            - count
            properties:
              emoji:
                type: string
              count:
                type: integer
        has_reacted:
          type: boolean
          description: Whether the CALLING user has reacted (any emoji) on this broadcast.
            Returned by GET /broadcasts/{id} only.
        my_reactions:
          type: array
          description: |
            The emoji(s) the CALLING user has reacted with on this broadcast
            (empty array when none). Same shape as the `my_reactions` field on
            POST /broadcasts/{id}/reactions/toggle, so the detail screen can
            restore the user's selection on load. Returned by GET /broadcasts/{id} only.
          items:
            type: string
            example: "\U0001F44D"
        comments_count:
          type: integer
          description: Number of (non-deleted) comments on this broadcast.
        unique_view_count:
          type: integer
          description: Distinct users who have viewed this broadcast.
        recent_comments:
          type: array
          description: The last 5 top-level comments, newest first.
          items:
            "$ref": "#/components/schemas/BroadcastComment"
        can_archive:
          type: boolean
          description: |
            Whether the caller can archive this broadcast. Mirrors the web archive
            guard exactly — true only for a PUBLISHED broadcast the caller can
            manage (admin/manager, or the author when a manager+). Returned by
            GET /broadcasts/{id} only.
        allow_comments:
          type: boolean
          description: Whether commenting is enabled on this broadcast (drives the
            comment composer). Returned by GET /broadcasts/{id} only.
        allow_reactions:
          type: boolean
          description: Whether reacting is enabled on this broadcast (drives the reaction
            bar). Returned by GET /broadcasts/{id} only.
        total_recipients:
          type: integer
          description: Recipients captured in the publish-time snapshot. Only when
            the caller can view stats.
        acknowledged_count:
          type: integer
          description: How many recipients have acknowledged. Only when the caller
            can view stats.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          description: When the CALLING user acknowledged (null if they haven't; present
            when require_acknowledgment).
    BroadcastAttachment:
      type: object
      required:
      - id
      - filename
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
          nullable: true
        byte_size:
          type: integer
        url:
          type: string
          description: ActiveStorage blob URL (attachment disposition).
    BroadcastComment:
      type: object
      required:
      - id
      - body
      - created_at
      properties:
        id:
          type: integer
        body:
          type: string
        created_at:
          type: string
          format: date-time
        author:
          "$ref": "#/components/schemas/BroadcastUser"
        attachments:
          type: array
          description: This comment's file attachments (DriveItem media).
          items:
            "$ref": "#/components/schemas/BroadcastAttachment"
        can_edit:
          type: boolean
          description: Whether the CALLING user can edit this comment — true only
            when they authored it.
        can_delete:
          type: boolean
          description: |
            Whether the CALLING user can delete this comment. Mirrors the web
            comment "delete" visibility — true for the comment's author OR an
            admin/above member.
    BroadcastCommentWriteResult:
      type: object
      description: Response for comment create / update.
      required:
      - success
      - comment
      properties:
        success:
          type: boolean
        held:
          type: boolean
          description: True when content moderation held the comment for review (hidden
            until approved).
        message:
          type: string
        comment:
          "$ref": "#/components/schemas/BroadcastComment"
    BroadcastInput:
      type: object
      description: |
        Writable broadcast attributes. On create (Send Now) supply at least one
        recipient target: `audience_id`, `extra_user_ids`, `audience_criteria`,
        or the top-level `notification_recipient_group_ids`.

        Referenced by `PATCH /broadcasts/{id}` only — the POST body inlines its
        own copy (with `description` required). Nothing is required here because
        PATCH is a PARTIAL edit: send just the attributes you are changing.
        Marking `title` and `description` required stopped a generated client
        from expressing the edit this endpoint is built for (toggling
        `publish_to_signage`, flipping `allow_comments`) without resending the
        whole body.

        `extra_user_ids` is accepted on CREATE only. Recipients and the
        `channels` mix are fixed at create time; sending either on a PATCH
        changes nothing and comes back named in `warnings`.
      properties:
        title:
          type: string
        description:
          type: string
        is_critical:
          type: boolean
        require_acknowledgment:
          type: boolean
        allow_comments:
          type: boolean
        allow_reactions:
          type: boolean
        audience_id:
          type: integer
          nullable: true
          description: A saved CommsHub audience to send to.
        extra_user_ids:
          type: array
          description: Specific user ids to send to (in addition to any groups/audience).
          items:
            type: integer
        audience_criteria:
          type: array
          description: |
            Attribute-based audience filters, each a typed hash — e.g.
            `{ "type": "role", "roles": ["member"] }`,
            `{ "type": "job_title", "titles": ["Area Manager"] }`,
            `{ "type": "department", "ids": [1,2] }`,
            `{ "type": "location", "ids": [3] }`.
          items:
            type: object
            additionalProperties: true
        publish_to_signage:
          type: boolean
          description: |
            Mark this broadcast for the break-room screen (Digital Signage)
            rotation — see the POST /broadcasts request body for what the
            marking does and does not promise.

            PATCH semantics: OMIT the key to leave the current selection alone;
            send `false` to take the broadcast back off the screens. The marker
            is removed rather than stored as `false`.
        signage_location_ids:
          type: array
          description: |
            Narrow `publish_to_signage` to specific sites. Empty means every
            screen. Ids outside the caller's business are dropped, and the key
            is ignored unless `publish_to_signage` is on.
          items:
            type: integer
    BroadcastListMeta:
      type: object
      description: |
        Pagination + segment counts for the list endpoint (parity with
        GET /inspections meta). All segment_counts are NOT-READ counts over the
        received inbox, independent of the active `filter`:
          * all         — received & not read by the caller
          * critical    — received & marked critical & not read
          * acknowledge — received & acknowledgment-required & not read
        (`all` is itself the unread total, so there is no separate `unread` key.)
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      - segment_counts
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        segment_counts:
          type: object
          required:
          - all
          - critical
          - acknowledge
          properties:
            all:
              type: integer
            critical:
              type: integer
            acknowledge:
              type: integer
    ChatAskAiIdentity:
      type: object
      description: The Ask AI assistant's admin-customizable identity. Shared shape
        for the boot config's ask_ai_account / ask_ai_chat and for GET /chat/ask_ai/identity.
        Blank description / history_tip mean "no custom copy" — the client keeps its
        own default subtitle and tip.
      properties:
        id:
          type: integer
          nullable: true
          description: Assistant principal's user id; null until provisioned.
        name:
          type: string
        avatar_url:
          type: string
          nullable: true
          description: Custom uploaded icon only; null ⇒ use the client's own glyph.
        avatar_updated_at:
          type: integer
          nullable: true
          description: Epoch seconds — cache-busting stamp.
        description:
          type: string
          nullable: true
        history_tip:
          type: string
          nullable: true
        history_search:
          type: boolean
          description: ask_ai_account only — retrieval-grounded answers are live for
            this tenant.
    CompanyStoreViewer:
      type: object
      description: Who is asking. THE only place role appears in a Company Store payload.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
          description: Absolute avatar URL, or null.
        is_manager:
          type: boolean
          description: 'Has a redemption approval queue — a designated approver-group
            member, or a line manager who has not been superseded by one. This is
            the ONLY difference between the design''s Employee and Manager personas
            ("everything above, plus approves orders"), and it is about the queue,
            not the job title: a `manager`-role user with no direct reports is `false`.
            On the dashboard it also decides whether `team_approvals` is present;
            on the catalog it changes nothing.'
        is_store_admin:
          type: boolean
          description: A business admin-or-above, or a Company Store app-admin. Offer
            the admin surfaces the web nav offers the same people. No catalog or dashboard
            field differs for them.
    CompanyStoreFeatures:
      type: object
      description: The TENANT switches that decide which surfaces exist at all. Hide
        a section on `features`, disable a control on `viewer`.
      properties:
        points_redemption_enabled:
          type: boolean
          description: Defaults to ON.
        cash_purchases_enabled:
          type: boolean
          description: Defaults to OFF — a tenant that never opted in must not be
            shown card checkout.
        mixed_payments_enabled:
          type: boolean
          description: Defaults to OFF.
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
          description: True when the tenant has at least one active region.
        points_per_dollar:
          type: integer
          description: Conversion rate for a split-payment slider. Coerced to a positive
            integer server-side.
        grid_limit:
          type: integer
          description: How many cards each fixed-size grid holds (the tenant's `featured_items_count`,
            clamped), so a carousel can size itself to what it will receive. Present
            on the DASHBOARD only — a paginated screen reports `meta.per_page` instead,
            so promising a grid size there would be a number with nothing to size.
    CompanyStoreRegion:
      type: object
      nullable: true
      description: The region this payload was scoped to. Null when regions are off
        — and also when they are on but this caller resolves to none, in which case
        the pool narrowed to global items only. `features.regions_enabled` distinguishes
        the two.
      properties:
        id:
          type: integer
        name:
          type: string
        country_code:
          type: string
    CompanyStoreBalance:
      type: object
      description: The caller's wallet.
      properties:
        points_balance:
          type: integer
        pending_points:
          type: integer
        lifetime_points_earned:
          type: integer
        lifetime_points_spent:
          type: integer
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
    CompanyStoreContext:
      type: object
      description: The blocks every catalog response reports. Identical for every
        role — read `viewer` for who is asking and `features` for what the tenant
        switched on.
      properties:
        viewer:
          "$ref": "#/components/schemas/CompanyStoreViewer"
        features:
          "$ref": "#/components/schemas/CompanyStoreFeatures"
        region:
          "$ref": "#/components/schemas/CompanyStoreRegion"
        available_regions:
          type: array
          description: Every active region, for a client's own picker. Empty when
            regions are off. The web only renders a switcher when there is more than
            one.
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
              country_code:
                type: string
              active:
                type: boolean
                description: True for the region this payload was scoped to.
        balance:
          "$ref": "#/components/schemas/CompanyStoreBalance"
        redemption:
          type: object
          properties:
            approval_threshold_points:
              type: integer
              nullable: true
              description: 'The points figure AT OR ABOVE which a redemption is HELD
                for approval instead of spending immediately — the lowest enabled
                tier. Inclusive: the checkout service compares `>=`, so an item priced
                exactly at this figure IS held, and `requires_approval` on an item
                detail agrees. Null when the tenant has no approval tier on. Disclose
                it BEFORE a one-tap redeem: that flow has nowhere else to say so.'
    CompanyStoreFilters:
      type: object
      description: The whole filter sheet — what is applied, and every alternative.
      properties:
        applied:
          type: object
          description: What the server actually applied. An unrecognised `category`
            / `sort` / `collection` is dropped, and this block is how a client sees
            that.
          properties:
            category:
              type: string
              nullable: true
            collection:
              type: string
              nullable: true
            featured:
              type: boolean
            search:
              type: string
              nullable: true
            sort:
              type: string
            region_id:
              type: integer
              nullable: true
        sort_options:
          type: array
          description: The same six strategies the desktop sort dropdown offers, in
            its order.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - featured
                - popular
                - price_low
                - price_high
                - name
                - newest
              label:
                type: string
              active:
                type: boolean
        categories:
          type: array
          description: '"All Categories" first with the true total, then one row per
            ENABLED category — INCLUDING rows at 0, so the sheet does not reshuffle
            as stock moves. Counts deliberately do NOT apply the active category or
            collection, because each row is an ALTERNATIVE to the current filter;
            they DO apply region, audience, category enablement and `search`. Use
            `has_items` to hide empty rows the way the web dropdown does.'
          items:
            type: object
            properties:
              key:
                type: string
                nullable: true
                description: Null on the "All Categories" row.
              label:
                type: string
                description: The storefront's merchandising label — "Donate", not
                  "Charitable"; "Gift Cards", not "Gift Card".
              icon:
                type: string
                description: Font Awesome name, the same glyph the web chip draws.
              color:
                type: string
                description: Bootstrap colour token, the same one the web chip uses.
              count:
                type: integer
              has_items:
                type: boolean
              active:
                type: boolean
        collections:
          type: array
          description: Merchandised collection chips, busiest first. Only collections
            that actually hold an item appear — unlike categories these are free-form
            per-tenant tags, so there is no fixed vocabulary to render an empty row
            for.
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              count:
                type: integer
              active:
                type: boolean
    CompanyStorePageMeta:
      type: object
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreItemCard:
      type: object
      description: |-
        One item as every Company Store grid renders it. IDENTICAL for every role — an employee, a manager and a store admin get the same fields with the same values for the same request. What varies per caller is the affordability block, `wishlisted`, `can_quick_redeem` and which items appear at all (audience + region).
        **Presentation extras are CATALOG-ONLY.** `category_label`, `category_icon`, `category_color`, `stock_label`, `stock_detail_label`, `has_variants`, `variant_types`, `digital_delivery`, `personalizable`, `wishlisted` and `can_quick_redeem` are added by the catalog endpoints on top of the shared card, because the catalog is the screen that renders chips, stock copy and a one-tap redeem button. The dashboard's `within_reach`, `featured_items` and `saved_items.items` carry every OTHER field here — identity, image, status, availability, region, the payment-gated prices and the affordability block — and omit the eleven above. Treat them as optional rather than assuming a grid card has them.
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          enum:
          - swag
          - gift_card
          - experience
          - charitable
        category_label:
          type: string
          description: The storefront's merchandising label ("Donate", "Gift Cards").
            Catalog endpoints only.
        category_icon:
          type: string
          description: Catalog endpoints only.
        category_color:
          type: string
          description: Catalog endpoints only.
        collection:
          type: string
          nullable: true
        image_url:
          type: string
          nullable: true
          description: Absolute URL. Prefers an Asset Library image, then an upload,
            then a provider catalog URL.
        featured:
          type: boolean
        status:
          type: string
          enum:
          - active
          - out_of_stock
          - discontinued
          - draft
          - coming_soon
        status_label:
          type: string
        available:
          type: boolean
          description: Active AND in stock. Everything buyable is gated on this.
        low_stock:
          type: boolean
        inventory_count:
          type: integer
          nullable: true
          description: Null means unlimited (print-on-demand, gift cards).
        stock_label:
          type: string
          description: '"In stock" / "Low stock" when the item is available. When
            it is not, the copy names the actual state rather than always saying out
            of stock: "Out of stock" for a stocked-out item, "Not yet published" for
            `draft` / `coming_soon`, "No longer available" for `discontinued` — because
            promising a restock that is never coming is worse than saying nothing.
            Same three-way split the web detail page renders. Catalog endpoints only.'
        stock_detail_label:
          type: string
          description: The count-carrying form — "Low stock (4 left)". Two strings
            rather than one so a card and a detail screen can differ without either
            client building copy out of a count. Catalog endpoints only.
        requires_shipping:
          type: boolean
          description: Admin-editable per item; falls back to the category default
            (only swag ships).
        digital_delivery:
          type: boolean
          description: Catalog endpoints only.
        personalizable:
          type: boolean
          description: Engraved per person — the engraving details are collected at
            checkout. Catalog endpoints only.
        has_variants:
          type: boolean
          description: Catalog endpoints only.
        variant_types:
          type: array
          items:
            type: string
          description: The variant types a client must collect before checkout. Empty
            when there are none. Catalog endpoints only.
        region:
          type: object
          nullable: true
          description: Null for a global item.
          properties:
            id:
              type: integer
            name:
              type: string
        points_price:
          type: integer
          nullable: true
          description: Present only while points redemption is ON and the item carries
            a points price. Null is a gate, not a missing value.
        cash_price:
          type: number
          format: float
          nullable: true
          description: Present only while card purchases are ON and the item carries
            a cash price.
        price_label:
          type: string
          description: The price cell's copy, under the same gates — "4500 pts", "4500
            pts or $45.00", "$45.00", or "Not currently redeemable" when no payment
            path is open.
        redeemable_with_points:
          type: boolean
        purchasable_with_cash:
          type: boolean
        affordable:
          type: boolean
          description: Against THIS caller's balance.
        points_to_go:
          type: integer
          nullable: true
          description: Shortfall in points. Null when there is no open points path.
        progress_percent:
          type: integer
          description: 0..100 for an affordability bar. 0 when there is no open points
            path.
        ready_to_redeem:
          type: boolean
          description: Affordable AND available. Both halves matter — a wishlist keeps
            out-of-stock items, which would otherwise read "ready".
        wishlisted:
          type: boolean
          description: Catalog endpoints only.
        can_quick_redeem:
          type: boolean
          description: 'The server would accept a one-tap redemption right now: points
            on, a points price, an enabled category, in stock, no variants, no shipping,
            no engraving, and affordable. Same guard set the redeem endpoint enforces.
            Catalog endpoints only.'
        url:
          type: string
          description: Absolute web URL for the item, so a client never hardcodes
            store paths.
    CompanyStoreItemDetail:
      allOf:
      - "$ref": "#/components/schemas/CompanyStoreItemCard"
      - type: object
        properties:
          large_image_url:
            type: string
            nullable: true
            description: Higher-resolution hero. Absolute.
          photos:
            type: array
            description: The primary image first, then the Asset Library gallery rows,
              in display order. Absolute URLs.
            items:
              type: object
              properties:
                url:
                  type: string
                alt:
                  type: string
          variants:
            type: array
            description: The variant pickers, in the item's own declared order.
            items:
              type: object
              properties:
                type:
                  type: string
                label:
                  type: string
                  description: Resolved server-side, so an unlabelled type reads "Size",
                    not "size".
                required:
                  type: boolean
                options:
                  type: array
                  items:
                    type: string
          item_details:
            type: object
            description: The web detail page's "Item Details" list.
            properties:
              category:
                type: string
              category_label:
                type: string
              sku:
                type: string
                nullable: true
              provider_type:
                type: string
              provider_label:
                type: string
              requires_shipping:
                type: boolean
              estimated_delivery_days:
                type: integer
                nullable: true
              delivery_label:
                type: string
                nullable: true
                description: '"5 business days" / "Shipping required" for a shipped
                  item, "Instant digital delivery" for a gift card, null when neither
                  applies.'
              fair_market_value:
                type: number
                format: float
                nullable: true
                description: The figure a tax statement reports against a redemption.
          payment_options:
            type: array
            description: One entry per path the ITEM has a price for. A method the
              item cannot be bought with at all is omitted; a method the TENANT switched
              off is present with a reason.
            items:
              type: object
              properties:
                key:
                  type: string
                  enum:
                  - points
                  - cash
                  - mixed
                label:
                  type: string
                points_required:
                  type: integer
                  nullable: true
                cash_required:
                  type: number
                  format: float
                  nullable: true
                available:
                  type: boolean
                block_reason:
                  type: string
                  nullable: true
                  description: Why not, in the caller's words — "Insufficient points",
                    "Card purchases aren't enabled for this store", "Split payments
                    aren't enabled for this store", the item's own unavailable copy
                    ("Out of stock", or "Not yet published" / "No longer available"
                    for a `draft`/`coming_soon` / `discontinued` item — same wording
                    as `stock_label`), or the neutral "This item isn't available for
                    redemption right now." for a switched-off category (deliberately
                    the same copy the audience refusal uses, so neither discloses
                    the other).
          requires_approval:
            type: boolean
            description: Would redeeming this item's points price be HELD for approval
              — `points_price >= redemption.approval_threshold_points`, the same inclusive
              comparison the checkout service makes. False when no tier is enabled.
          category_enabled:
            type: boolean
            description: False for a deep link into a category the admin switched
              off. The item still renders — the web page does too — but nothing about
              it is buyable.
          restock_watch:
            type: boolean
            description: Whether the caller has a back-in-stock ("notify me") watch
              on this item.
          related_items:
            type: array
            description: Up to four more from the same category, under the same region
              and audience filters as the grid — an item whose own page would refuse
              must not appear here either.
            items:
              "$ref": "#/components/schemas/CompanyStoreItemCard"
    CompanyStoreOrderRow:
      type: object
      description: 'One order row. `item` is nil-tolerant: an order whose item row
        has since been deleted still renders, with `item_name` falling back to "Item".'
      properties:
        id:
          type: integer
        order_number:
          type: string
        item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
        item_name:
          type: string
        quantity:
          type: integer
        status:
          type: string
        status_label:
          type: string
          description: Through the canonical helper, so `pending_approval` reads "Awaiting
            Approval" here exactly as it does on every other store surface.
        points_spent:
          type: integer
        cash_amount:
          type: number
          format: float
        total_label:
          type: string
        placed_at:
          type: string
          format: date-time
        url:
          type: string
    CompanyStoreApprovalRequest:
      type: object
      description: One row of the manager Team Approvals widget, oldest-waiting first.
      properties:
        id:
          type: integer
        order_number:
          type: string
        requester:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            title:
              type: string
              nullable: true
            image:
              type: string
              nullable: true
        item_name:
          type: string
        points:
          type: integer
        quantity:
          type: integer
        waiting_since:
          type: string
          format: date-time
          description: What the widget sorts by — a manager is holding these up.
        status:
          type: string
        status_label:
          type: string
        can_approve:
          type: boolean
          description: 'Always true for a row in this list: the queue IS the approvable
            set for this viewer. The decision endpoints still enforce the rule on
            write, which is where a tier change under a stale list is caught.'
    CompanyStoreError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            details:
              type: object
              nullable: true
    CompanyStoreApprovalQueue:
      type: object
      properties:
        viewer:
          type: object
          description: Who is asking. `is_manager` is always `true` in a 200 here
            (the endpoint 403s otherwise) and is reported so one client model covers
            every screen in this namespace.
          properties:
            id:
              type: integer
            name:
              type: string
            image:
              type: string
              nullable: true
            is_manager:
              type: boolean
              example: true
            is_store_admin:
              type: boolean
              description: 'A business admin-or-above or a Company Store app admin.
                On THIS endpoint it is load-bearing: it is what widens the pool to
                the whole tenant at every approval tier (bounded by the token — a
                narrowed `own_company_store` token reads the manager queue even for
                an admin, so a `true` here does not by itself prove the wide pool
                was served; `scope` does).'
        features:
          type: object
          description: The TENANT switches, the same block the dashboard / catalog
            / orders endpoints report. Present so a client renders one store shell
            from any call.
          properties:
            points_redemption_enabled:
              type: boolean
            cash_purchases_enabled:
              type: boolean
            mixed_payments_enabled:
              type: boolean
            recognition_integration_enabled:
              type: boolean
            regions_enabled:
              type: boolean
            points_per_dollar:
              type: integer
              example: 100
        scope:
          type: string
          enum:
          - company
          - team
          description: '`company` — a store admin (every tier) or a designated approver-group
            member (manager tier); the queue spans the whole tenant. `team` — a line
            manager; the queue is their own direct reports''. Drives the screen''s
            subtitle: calling a committee''s rows "your team" mislabels every one
            of them.'
        filters:
          type: object
          description: What was actually APPLIED, after clamping and fallbacks.
          properties:
            sort:
              type: string
              enum:
              - oldest
              - newest
            per_page:
              type: integer
              example: 20
        total_pending:
          type: integer
          description: The FULL queue depth — how many redemptions are waiting on
            this approver in total, independent of `page` / `per_page`. This is the
            tab badge figure, and it equals the dashboard widget's `team_approvals.pending_count`.
          example: 7
        items:
          type: array
          items:
            "$ref": "#/components/schemas/CompanyStoreApprovalQueueRow"
        meta:
          "$ref": "#/components/schemas/CompanyStoreApprovalPageMeta"
    CompanyStoreApprovalQueueRow:
      type: object
      description: One held redemption as the Approvals SCREEN renders it — the shared
        `CompanyStoreApprovalRequest` card plus the six fields a full-width queue
        row shows and a four-row dashboard widget doesn't.
      properties:
        id:
          type: integer
          description: The StoreOrder id — what the decision endpoints take.
        order_number:
          type: string
          example: ORD-20260731-1AOOOK
        requester:
          type: object
          description: The employee whose redemption is held.
          properties:
            id:
              type: integer
            name:
              type: string
              example: Maya Chen
            title:
              type: string
              nullable: true
              example: Support Specialist
            image:
              type: string
              nullable: true
              description: Absolute avatar URL.
        requester_department:
          type: string
          nullable: true
          description: The requester's organizational department — the design's "Support
            · Jul 18" sub-line. `null` for a user with no department; the client then
            prints the date alone.
          example: Support
        item_name:
          type: string
          description: Falls back to `"Item"` when the item row has since been deleted,
            so a row always renders.
          example: Company Logo Hoodie
        item:
          type: object
          nullable: true
          description: "`null` when the item row has been deleted."
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
              description: Absolute URL.
        points:
          type: integer
          description: The points held on this redemption — what a decline returns.
          example: 4500
        points_label:
          type: string
          description: Pre-formatted with the same delimiter the web table uses, so
            the surfaces never disagree.
          example: 4,500 pts
        quantity:
          type: integer
          example: 1
        variant_display:
          type: string
          nullable: true
          description: '`"Size: L, Color: Black"`. `null` when the item has no variants.'
        waiting_since:
          type: string
          format: date-time
          description: When the redemption was placed — what `sort` orders by.
        waiting_days:
          type: integer
          description: Whole days waited, computed server-side so every client's "3
            days" agrees with every other's.
          example: 3
        status:
          type: string
          example: pending_approval
        status_label:
          type: string
          description: The canonical store copy, via the same helper every other store
            surface uses — `"Awaiting Approval"`, never `"Pending Approval"`.
          example: Awaiting Approval
        can_approve:
          type: boolean
          description: 'May THIS caller decide THIS row. `true` for every row on the
            manager branch, where the queue is the approvable set. On the admin branch
            the pool is wider than the approvable set, so it can be `false`: a Company
            Store app admin who is not a business admin sees an admin-tier hold they
            may not release — the same row the web admin page draws with its buttons
            disabled. Render the controls off this flag; a decision POST on a `false`
            row answers `403 not_an_approver`. The decision endpoints enforce the
            rule on write regardless, which is where a tier change under a stale list
            is caught.'
        order_url:
          type: string
          description: Absolute deep link to the order's own page.
    CompanyStoreApprovalDecisionResponse:
      type: object
      properties:
        approval:
          allOf:
          - "$ref": "#/components/schemas/CompanyStoreApprovalRequest"
          - type: object
            properties:
              decision:
                type: string
                enum:
                - approved
                - declined
              decided_at:
                type: string
                format: date-time
              decided_by:
                type: object
                description: The approver who took the decision.
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  title:
                    type: string
                    nullable: true
                  image:
                    type: string
                    nullable: true
              reason:
                type: string
                nullable: true
                description: The decline reason as recorded. `null` on an approve,
                  and on a decline sent without one.
              points_refunded:
                type: integer
                description: What went back to the employee's wallet. The held points
                  on a decline; `0` on an approve, where the points stay spent.
                example: 4500
              status:
                type: string
                description: The RESULTING order status — `pending` after an approve
                  (now queued for fulfillment), `cancelled` after a decline.
                example: pending
              can_approve:
                type: boolean
                description: "`false` — the row has left the queue."
        total_pending:
          type: integer
          description: The queue depth AFTER this decision, so a client's tab badge
            updates from this response.
        message:
          type: string
          description: The same copy the web flash shows, so both surfaces tell an
            approver the same thing.
          example: Approved
        unread_notification_count:
          type: integer
    CompanyStoreApprovalPageMeta:
      type: object
      description: The page envelope every paginated endpoint in this namespace answers
        with. `total_count` equals `total_pending` here, because nothing narrows the
        queue.
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreApprovalError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - store_disabled
              - approvals_delegated
              - not_an_approver
              - not_found
              - decision_failed
            message:
              type: string
    CompanyStoreCartLine:
      type: object
      properties:
        id:
          type: integer
        item:
          type: object
          description: The item card
          as the catalog renders it.:
        quantity:
          type: integer
        max_quantity:
          type: integer
          description: min(5, stock) — the stepper's ceiling.
        selected_variants:
          type: object
          additionalProperties:
            type: string
        unit_points:
          type: integer
        unit_cash:
          type: number
        line_points:
          type: integer
        line_cash:
          type: number
        issue:
          type: string
          nullable: true
          description: Why this line can't check out as it stands; null when it can.
    CompanyStoreCartResponse:
      type: object
      properties:
        cart:
          type: object
          properties:
            id:
              type: integer
              nullable: true
            line_count:
              type: integer
            units:
              type: integer
            fulfillment_route:
              type: string
              nullable: true
              enum:
              - printful
              - managed
              - internal
            requires_shipping:
              type: boolean
            currency_code:
              type: string
              nullable: true
            limits:
              type: object
              properties:
                max_lines:
                  type: integer
                  example: 5
                max_quantity_per_line:
                  type: integer
                  example: 5
            lines:
              type: array
              items:
                "$ref": "#/components/schemas/CompanyStoreCartLine"
            totals:
              type: object
              properties:
                points:
                  type: integer
                cash:
                  type: number
                cash_cents:
                  type: integer
            blockers:
              type: array
              items:
                type: string
              description: Why a points checkout would be refused, in the buyer's
                words. Empty when it wouldn't.
        wallet:
          type: object
          description: The caller's balance card.
        added_line_id:
          type: integer
          description: POST /cart/lines only.
        warnings:
          type: array
          items:
            type: string
    CompanyStoreCartError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - unavailable
              - ineligible
              - route_mismatch
              - currency_mismatch
              - cart_full
              - out_of_stock
              - variant_invalid
              - region
              - line_not_found
            message:
              type: string
              description: The web's own sentence.
    CompanyStoreCartCheckoutPreview:
      type: object
      properties:
        checkout:
          type: object
          properties:
            lines:
              type: array
              items:
                "$ref": "#/components/schemas/CompanyStoreCartLine"
            totals:
              type: object
              properties:
                points:
                  type: integer
                cash:
                  type: number
                cash_cents:
                  type: integer
                units:
                  type: integer
            currency:
              type: string
              nullable: true
            payment_options:
              type: array
              items:
                type: object
                properties:
                  key:
                    type: string
                    enum:
                    - points
                    - cash
                    - mixed
                  label:
                    type: string
                  available:
                    type: boolean
                  block_reason:
                    type: string
                  amount:
                    type: object
                    description: "`{ points }`, `{ cash }` or `{ points_max }` (the
                      split slider's ceiling)."
            selected_payment:
              type: string
              nullable: true
            can_checkout:
              type: boolean
            shortfall:
              type: integer
              description: Points short of the total; 0 when covered.
            wallet:
              type: object
            shipping:
              type: object
              properties:
                required:
                  type: boolean
                collected_on_stripe_for:
                  type: array
                  items:
                    type: string
                  description: Payment types for which Stripe collects the address
                    (skip your form for these).
                prefill:
                  type: object
                countries_url:
                  type: string
            disclosures:
              type: object
              properties:
                required_approver:
                  type: string
                  nullable: true
                velocity_hold_reason:
                  type: string
                  nullable: true
    CompanyStoreCartCheckoutResponse:
      type: object
      properties:
        placed:
          type: boolean
        order:
          type: object
          description: The order card.
        held_for_approval:
          type: boolean
        payment:
          type: object
          description: Present only when `placed` is false.
          properties:
            checkout_url:
              type: string
            session_id:
              type: string
            return_url_prefix:
              type: string
        wallet:
          type: object
        message:
          type: string
    CompanyStoreOrderRequest:
      type: object
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - question
          - cancellation
          - delivery_status
          - address_change
          - change_selection
          - damaged
          - code_issue
          - payment
          - other
        kind_label:
          type: string
        status:
          type: string
          enum:
          - open
          - approved
          - declined
          - answered
        message:
          type: string
        resolution_notes:
          type: string
          nullable: true
          description: The admin's reply or reason, once decided.
        created_at:
          type: string
          format: date-time
        processed_at:
          type: string
          format: date-time
          nullable: true
    CompanyStoreCheckoutRequest:
      type: object
      required:
      - payment_type
      properties:
        payment_type:
          type: string
          enum:
          - points
          - cash
          - mixed
          description: How to pay. `points` places the order in this request; `cash`
            and `mixed` return a Stripe URL. Re-checked against the tenant's toggles
            server-side.
        quantity:
          type: integer
          default: 1
          description: Clamped to 1..99, and bounded again by the item's inventory.
        points_to_use:
          type: integer
          description: "`mixed` only — how many points to put toward the price. **Forced
            to 0 for `cash`**, so a hidden slider cannot spend points on a card-only
            submit. If it covers the whole price the service routes the order to the
            points path and the response comes back `placed: true`."
        variants:
          type: object
          additionalProperties:
            type: string
          description: 'Required when the item declares options — `{"size": "L", "color":
            "Black"}`. Keys the item never declared are dropped. An incomplete selection
            is refused with `variants_required`.'
          example:
            size: L
            color: Black
        shipping_address:
          "$ref": "#/components/schemas/CompanyStoreCheckoutShippingAddress"
        personalization:
          type: object
          description: Engraving details, for a personalizable item.
          properties:
            recipient_name:
              type: string
            years_of_service:
              type: string
            engraving_line:
              type: string
        idempotency_key:
          type: string
          description: "**Send one.** A client-generated key (a UUID per checkout
            attempt). A repeat returns the same order rather than placing a second
            one and debiting the points twice. Scoped to the caller, so another user's
            key can never resolve to their order."
          example: 8f14e45f-ea0b-4c1e-9d8a-2b3c4d5e6f70
    CompanyStoreCheckoutShippingAddress:
      type: object
      description: Where it ships. Send when `shipping.required` is true AND `shipping.collected_by`
        is `app`; when it is `stripe`, Stripe collects the address on its own page
        and copies it back onto the order.
      properties:
        name:
          type: string
        street1:
          type: string
        street2:
          type: string
        city:
          type: string
        state:
          type: string
        zip:
          type: string
        country:
          type: string
        phone:
          type: string
    CompanyStoreCheckoutPaymentOption:
      type: object
      description: One payment path, and the reason it can't be taken right now. A
        path the item has no price for is omitted entirely; a path the TENANT switched
        off is present with a reason, because that is a state an admin can change.
      properties:
        key:
          type: string
          enum:
          - points
          - cash
          - mixed
        label:
          type: string
          example: Redeem with Points
        points_required:
          type: integer
          nullable: true
        cash_required:
          type: number
          nullable: true
        available:
          type: boolean
        block_reason:
          type: string
          nullable: true
          description: Buyer-facing reason this path is unavailable — show it instead
            of a silently greyed-out button. Null when `available`.
    CompanyStoreCheckoutTotals:
      type: object
      description: What each path costs at the requested quantity.
      properties:
        points:
          type: object
          properties:
            points:
              type: integer
            cash:
              type: number
              example: 0.0
        cash:
          type: object
          properties:
            points:
              type: integer
              example: 0
            cash:
              type: number
        mixed:
          type: object
          properties:
            max_points:
              type: integer
              description: 'The slider ceiling — `min(points needed to cover the whole
                price, the caller''s balance)`. Do not allow past it: there would
                be no card portion left, and the write would silently become a points
                redemption.'
            points_per_dollar:
              type: integer
              description: The tenant's conversion rate.
            requested_points:
              type: integer
              description: Echo of the `points_to_use` this preview was asked about.
            cash_after_points:
              type: number
              description: What the card is charged for `requested_points`.
    CompanyStoreCheckoutPreviewResponse:
      type: object
      properties:
        checkout:
          type: object
          properties:
            item:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
                image_url:
                  type: string
                  nullable: true
                points_price:
                  type: integer
                cash_price:
                  type: number
                currency:
                  type: string
                  example: USD
            quantity:
              type: object
              properties:
                selected:
                  type: integer
                min:
                  type: integer
                  example: 1
                max:
                  type: integer
                  description: Bounded by the item's tracked inventory when it tracks
                    any.
            payment_options:
              type: array
              items:
                "$ref": "#/components/schemas/CompanyStoreCheckoutPaymentOption"
            selected_payment:
              type: string
              nullable: true
              description: The path to open on — the caller's requested `payment_type`
                when this item can take it, otherwise the first available one.
            can_checkout:
              type: boolean
              description: Is ANY path available. False for an unbuyable item (still
                a 200).
            totals:
              "$ref": "#/components/schemas/CompanyStoreCheckoutTotals"
            wallet:
              "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
            variants:
              type: array
              description: The option pickers to render, in the item's declared order.
              items:
                type: object
                properties:
                  type:
                    type: string
                    example: size
                  label:
                    type: string
                    example: Size
                  required:
                    type: boolean
                  options:
                    type: array
                    items:
                      type: string
            shipping:
              type: object
              properties:
                required:
                  type: boolean
                collected_by:
                  type: string
                  enum:
                  - app
                  - stripe
                  description: "`stripe` when Stripe Tax is on for a shippable item
                    — Stripe collects the address on its own page so `automatic_tax`
                    can price destination tax, and your form must skip its address
                    step. Points-only orders never reach Stripe, so they always collect
                    in-app."
                collected_by_stripe_for:
                  type: array
                  description: Which payment paths Stripe collects for (`[]`, or cash+mixed).
                  items:
                    type: string
                fields:
                  type: array
                  items:
                    type: string
                regions:
                  type: object
                  description: |-
                    The two address dropdowns' CONTENTS — the shippable countries and, nested under each, its states/provinces. `fields` above names the boxes; this says what each one accepts, which is what stops a native form from free-texting a state the fulfillment path reads as a 2-letter `state_code`.

                    Present whenever `required` is true — including when `collected_by` is `stripe`, because that collector is per PAYMENT PATH (`collected_by_stripe_for` is only cash/mixed) and the points-only path on the same item always collects in-app. Absent on a digital item, which has no shipping section.

                    Byte-identical to the `shipping_countries` payload of `GET /company-store/shipping-countries` (one server-side builder feeds both), so the normal purchase flow needs no extra round trip — call that endpoint only to cache the list at launch or to render an address form outside a checkout. Read its documentation for the rule that matters: country submits the display NAME, state submits the 2-letter CODE.
                  properties:
                    countries:
                      type: array
                      description: Never empty. Render whatever length this holds
                        rather than assuming one entry.
                      items:
                        "$ref": "#/components/schemas/CompanyStoreShippingCountry"
                    default_country:
                      type: string
                      description: The country to select when the caller has expressed
                        no preference — always one of the rows' `value`s.
                      example: United States
                    prefill:
                      "$ref": "#/components/schemas/CompanyStoreShippingPrefill"
            personalization:
              type: object
              description: The engraving block. `required` is whether this item is
                engraved at all; the per-field flags below say which boxes the server
                actually insists on, so a form need not mark all three required.
              properties:
                required:
                  type: boolean
                fields:
                  type: array
                  description: 'One entry per engraving field, with the server''s
                    own rules. Enforce `max_length` in the form: the service refuses
                    past it (`checkout_failed`), and discovering that after the buyer
                    has committed is the whole reason it is disclosed here.'
                  items:
                    type: object
                    properties:
                      key:
                        type: string
                        example: recipient_name
                      label:
                        type: string
                        example: Recipient name
                      required:
                        type: boolean
                        description: Today only `recipient_name` is required.
                      max_length:
                        type: integer
                        example: 80
            category_enabled:
              type: boolean
              description: False when the admin has switched this item's category
                off. The item still renders here (with every `payment_options` entry
                blocked) so a deep link is not a dead end, but the write refuses with
                `item_unavailable`. Same field GET /company-store/catalog/{id} reports.
            disclosures:
              "$ref": "#/components/schemas/CompanyStoreCheckoutDisclosures"
    CompanyStoreCheckoutDisclosures:
      type: object
      description: What will happen when the buyer commits — said before they commit,
        which for a one-tap redeem is the only place it can be said.
      properties:
        requires_approval:
          type: boolean
          description: Will this redemption be HELD for approval instead of spending
            immediately.
        approver_tier:
          type: string
          nullable: true
          enum:
          - admin
          - manager
          -
          description: Which tier the hold routes to. Null when nothing holds it.
        velocity_hold_reason:
          type: string
          nullable: true
          description: The short-window fraud brake tripped. It does not block the
            order, it forces an admin hold — disclose it as "this may need approval",
            not as a refusal.
        applies_to:
          type: array
          description: Which payment paths the hold applies to. A hold is on the POINTS
            portion, so a pure-card checkout is never held.
          items:
            type: string
        monthly_points_cap:
          type: integer
          nullable: true
        monthly_points_remaining:
          type: integer
          nullable: true
    CompanyStoreCheckoutWallet:
      type: object
      description: The caller's points wallet. On a write response this is the balance
        AFTER the debit (or, for an abandon, after the refund) — render from it rather
        than re-asking /config, which would show a stale number. Same six fields as
        `CompanyStoreBalance` in company_store.yaml — both are the namespace's one
        `balance_card`, so a client parses one shape.
      properties:
        points_balance:
          type: integer
        pending_points:
          type: integer
        lifetime_points_earned:
          type: integer
        lifetime_points_spent:
          type: integer
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
    CompanyStoreCheckoutPayment:
      type: object
      description: Present only when `placed` is false. What to open, and where to
        confirm.
      properties:
        provider:
          type: string
          enum:
          - stripe
        checkout_url:
          type: string
          description: Open this in the system browser (ASWebAuthenticationSession
            / Chrome Custom Tabs). Not a WebView you built — the buyer needs to see
            the real stripe.com address.
          example: https://checkout.stripe.com/c/pay/cs_test_a1B2c3
        session_id:
          type: string
          example: cs_test_a1B2c3
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Stripe's own session expiry (24h). After it, confirm reports
            the order's real state.
        currency:
          type: string
          example: USD
        cash_amount:
          type: number
          description: What the card will be charged.
        points_held:
          type: integer
          description: The points portion
          already debited.:
        return_url_prefix:
          type: string
          description: Watch the in-browser navigation for this PREFIX (Stripe appends
            its own `?session_id=`), then close the browser and call `complete_url`.
            Intercepting is an optimisation — polling `complete_url` works without
            it.
        cancel_url_prefix:
          type: string
          description: Same, for the buyer pressing Stripe's back link — then call
            `abandon_url`.
        complete_url:
          type: string
          description: Idempotent. Poll it.
        abandon_url:
          type: string
    CompanyStoreCheckoutWriteResponse:
      type: object
      description: The response to the write. **Branch on `placed`** — a `mixed` checkout
        the points fully covered comes back placed with no `payment` block.
      properties:
        placed:
          type: boolean
          description: |-
            True — the order is placed, nothing further is owed. False — open `payment.checkout_url`.
            `placed` is derived from the ORDER's own state, never from whether a `payment` block happens to be present, so `true` can always be trusted as "nothing is owed". A 200 therefore never carries `false` without a `payment` block: the one case where an order exists, money is owed and no session can be returned (an idempotent retry whose Stripe session expired or closed) is answered `409 checkout_session_unavailable` instead.
        order:
          "$ref": "#/components/schemas/CompanyStoreCheckoutOrderCard"
        requires_approval:
          type: boolean
          description: Points paths only. True means the order is `pending_approval`
            with the points HELD — report the hold, do not celebrate.
        payment:
          "$ref": "#/components/schemas/CompanyStoreCheckoutPayment"
        wallet:
          "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
        message:
          type: string
          description: Buyer-facing confirmation. Safe to show verbatim.
          example: Order
    CompanyStoreCheckoutCompleteResponse:
      type: object
      properties:
        placed:
          type: boolean
          example: true
        order:
          "$ref": "#/components/schemas/CompanyStoreCheckoutOrderCard"
        wallet:
          "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
        message:
          type: string
          example: 'Payment received. Order #ORD-20260819-ABC123 is confirmed.'
        unread_notification_count:
          type: integer
          description: Native app badge count (the shared api/v1 envelope).
    CompanyStoreCheckoutAbandonResponse:
      type: object
      properties:
        cancelled:
          type: boolean
          example: true
        order:
          "$ref": "#/components/schemas/CompanyStoreCheckoutOrderCard"
        points_restored:
          type: boolean
        points_restored_amount:
          type: integer
        wallet:
          "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
        message:
          type: string
          example: Checkout cancelled. 700 points have been returned to your balance.
        unread_notification_count:
          type: integer
          description: Native app badge count (the shared api/v1 envelope).
    CompanyStoreCheckoutOrderCard:
      type: object
      description: The shared Company Store order row — the same shape the dashboard's
        recent orders and the orders list render, so one order never looks like two
        different things. Full detail is `GET /company-store/orders/{order_number}`.
      properties:
        id:
          type: integer
        order_number:
          type: string
          example: ORD-20260819-ABC123
        item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
        item_name:
          type: string
        quantity:
          type: integer
        status:
          type: string
          enum:
          - pending_approval
          - pending
          - processing
          - fulfilled
          - cancelled
          - refunded
        status_label:
          type: string
          description: Canonical label — `pending_approval` reads "Awaiting Approval"
            on every surface.
        points_spent:
          type: integer
        cash_amount:
          type: number
        total_label:
          type: string
        placed_at:
          type: string
          format: date-time
        url:
          type: string
    CompanyStoreCheckoutError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Machine-readable. See each response's description for the
                codes it can return.
            message:
              type: string
              description: Buyer-facing. For `checkout_failed` this is the service's
                own prose (with the real numbers in it) — show it.
            details:
              type: object
              nullable: true
              description: 'Present on the 409s: `order` carries the order''s current
                state for a polling client, and `cancel_url` on `not_abandonable`
                points at the endpoint to use instead.'
    CompanyStoreConfigViewer:
      type: object
      description: Who is asking. The namespace's shared viewer card, identical on
        every Company Store endpoint. This is the ONLY place role appears in this
        payload.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
        is_manager:
          type: boolean
          description: 'Does this viewer have a redemption approval queue — Store::RedemptionApprovalService.can_approve_redemptions?,
            the same predicate the approvals queue''s own door gates on. THE flag
            that gates the manager UI. True for a designated approver-group member,
            and for a line manager with direct reports when no group is configured;
            NOT a `role: manager` membership check.'
        is_store_admin:
          type: boolean
          description: A business admin-or-above or a Company Store app-admin. Offers
            the item / order management surfaces.
    CompanyStoreConfigFeatures:
      type: object
      description: The TENANT switches that decide which surfaces exist at all. The
        first six are shared verbatim with the other Company Store endpoints; `points_expiry_enabled`
        is merged on here for the same reason the Points tab merges it.
      properties:
        points_redemption_enabled:
          type: boolean
          description: Default true. Whether the store takes points at all.
        cash_purchases_enabled:
          type: boolean
          description: Default FALSE. A tenant that never opted in must not be told
            cash checkout exists.
        mixed_payments_enabled:
          type: boolean
          description: Default false. The RAW admin toggle — NOT whether a split payment
            can be taken, which additionally needs both of the two flags above. Use
            `payment_methods` for what is actually offerable.
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
          description: Whether the tenant has any active region, i.e. whether region
            scoping is in force.
        points_per_dollar:
          type: integer
          description: The conversion rate a mixed-payment slider needs. Coerced server-side
            to a positive Integer (admins have saved it as a string).
          example: 100
        points_expiry_enabled:
          type: boolean
          description: 'Whether the tenant expires points at all. Distinguishes `points.expiring_points:
            0` meaning "nothing is close" from "this tenant does not expire points".'
    CompanyStoreConfigRegion:
      type: object
      nullable: true
      description: The region this payload was scoped to. null when regions are off,
        or when they are on and this user resolves to none (the pool then narrowed
        to global items) — `features.regions_enabled` distinguishes the two.
      properties:
        id:
          type: integer
        name:
          type: string
        country_code:
          type: string
          nullable: true
    CompanyStoreConfigAvailableRegion:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        country_code:
          type: string
          nullable: true
        active:
          type: boolean
          description: True for the region this payload was scoped to.
    CompanyStoreConfigCategory:
      type: object
      description: 'One row of the category-filter bottom sheet. The first row of
        the array is always the "All Categories" row, which carries `key: null` and
        the true total.'
      properties:
        key:
          type: string
          nullable: true
          description: The value `?category=` takes VERBATIM — the StoreItem column
            vocabulary, NOT the merchandising label. null on the "All Categories"
            row.
          enum:
          - swag
          - gift_card
          - experience
          - charitable
          -
        label:
          type: string
          description: The storefront's merchandising label, which deliberately differs
            from `key` — `charitable` is sold as "Donate", and the other three are
            pluralised.
          example: Gift Cards
        icon:
          type: string
          description: A stable FontAwesome-style icon key, never an image URL. `tag`
            on the All row; `tshirt` / `gift` / `star` / `hand-holding-heart` for
            the four categories, with `box` as the fallback. The same keys the web
            storefront renders.
          example: gift
        color:
          type: string
          description: Bootstrap contextual colour for the chip, shared with the web.
          example: success
        count:
          type: integer
          description: How many available items this row would land on, under the
            same region / audience / category-enablement filters the catalog listing
            applies. Computed in ONE grouped query for the whole sheet. A count of
            0 means the category is ON and empty — a category the admin switched OFF
            is absent from the array entirely.
        has_items:
          type: boolean
          description: "`count > 0`. For a client that wants the web dropdown's behaviour
            of hiding empty categories."
        active:
          type: boolean
          description: Which row is selected. Always the "All Categories" row on this
            endpoint, since a bootstrap has no applied filter.
    CompanyStoreConfigPaymentMethod:
      type: object
      description: One payment method the TENANT accepts. Only enabled methods appear.
        This is the ceiling — an individual item may offer fewer (see the item detail's
        `payment_options`) but never one outside this array.
      properties:
        key:
          type: string
          enum:
          - points
          - cash
          - mixed
        label:
          type: string
          example: Points
        description:
          type: string
          example: Redeem with your points balance
    CompanyStoreConfigPoints:
      type: object
      description: The caller's OWN wallet, for every role. Plain-noun keys; the screen
        endpoints serve the same figures under the namespace's `balance_card` spelling
        (`points_balance`, `pending_points`, …).
      properties:
        balance:
          type: integer
          description: Spendable points.
        pending:
          type: integer
          description: Earned but not yet released.
        lifetime_earned:
          type: integer
        lifetime_spent:
          type: integer
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
        points_per_dollar:
          type: integer
          description: Mirrors `features.points_per_dollar`, carried here so the wallet
            block is self-contained.
        expiring_points:
          type: integer
          description: What NEWLY expires within `expiring_within_days` — never the
            whole expirable pool. Always 0 when `features.points_expiry_enabled` is
            false.
        expiring_within_days:
          type: integer
          description: The expiry warning horizon (ExpireStorePointsJob::WARN_DAYS).
          example: 14
    CompanyStoreConfigRedemption:
      type: object
      description: The rules a redemption is subject to before it becomes an order,
        mirroring Store::CheckoutService's own readings so a client can disclose a
        hold or a cap BEFORE the user commits. Every figure is null when off, never
        0.
      properties:
        approval_threshold_points:
          type: integer
          nullable: true
          description: The points figure AT OR ABOVE which a redemption is HELD for
            approval instead of spending immediately (checkout compares `>=`). The
            LOWEST enabled of the admin and manager tiers, since that is the one that
            actually holds; a tier set to 0 is off. null = no tier enabled.
        monthly_points_cap:
          type: integer
          nullable: true
          description: Per-user calendar-month ceiling on redeemed points. null =
            unlimited.
        monthly_points_remaining:
          type: integer
          nullable: true
          description: What is left of that ceiling for THIS caller this month, counting
            the same set checkout counts (points and mixed orders, cancelled and refunded
            excluded). Floored at 0. null when there is no cap — and the query behind
            it is then skipped entirely.
        velocity_window_hours:
          type: integer
          description: The short-window fraud brake's window. Reports the checkout
            service's own 24h fallback when unset.
          example: 24
        velocity_max_orders:
          type: integer
          nullable: true
          description: Redemptions allowed inside the window. A breach does NOT block
            — it forces the approval hold. null = off.
        velocity_max_points:
          type: integer
          nullable: true
          description: Points allowed inside the window. Same non-blocking hold semantics.
            null = off.
    CompanyStoreOrdersViewer:
      type: object
      description: Who is asking. Shared verbatim with every other Company Store endpoint.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
        is_manager:
          type: boolean
          description: Does this viewer have a redemption approval queue — the one
            thing that separates the design's Manager persona from its Employee one.
            It does NOT widen the order list; the approval queue is its own surface.
        is_store_admin:
          type: boolean
          description: A business admin/owner or a Company Store app-admin.
    CompanyStoreOrdersFeatures:
      type: object
      description: The TENANT switches, shared verbatim with the other Company Store
        endpoints. Present on the orders payload because the reorder affordance depends
        on them — an item is only reorderable through a payment path the tenant actually
        enabled.
      properties:
        points_redemption_enabled:
          type: boolean
        cash_purchases_enabled:
          type: boolean
        mixed_payments_enabled:
          type: boolean
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
        points_per_dollar:
          type: integer
    CompanyStoreOrderStatusCounts:
      type: object
      description: Order counts by status from ONE grouped query. Every key is always
        present (0 when empty), so a filter bar renders a stable set of pills instead
        of reading an absent key as zero. Honours `search`, not `status`.
      required:
      - all
      - pending_approval
      - pending
      - processing
      - fulfilled
      - cancelled
      - refunded
      properties:
        all:
          type: integer
          description: Every order in the scope, in every status — the "All" pill.
          example: 14
        pending_approval:
          type: integer
          example: 1
        pending:
          type: integer
          example: 2
        processing:
          type: integer
          example: 3
        fulfilled:
          type: integer
          example: 6
        cancelled:
          type: integer
          example: 2
        refunded:
          type: integer
          example: 0
    CompanyStoreOrderStatusFilter:
      type: object
      description: One pill in the filter row.
      properties:
        value:
          type: string
          nullable: true
          description: The `status` query value this pill applies. **null** is the
            All pill.
        label:
          type: string
          description: Display label. `pending_approval` reads "Awaiting Approval".
          example: Awaiting Approval
        count:
          type: integer
          description: The same figure `counts` reports for this status.
        selected:
          type: boolean
          description: True for the pill matching the applied filter (the All pill
            when none is applied).
        visible:
          type: boolean
          description: Whether the web chip row would show this pill. False only for
            `pending_approval` / `refunded` while they have no rows and are not selected.
    CompanyStoreOrderListRow:
      type: object
      description: One row in the order list — the shared `CompanyStoreOrderRow` card
        plus the three fields the orders screen adds, and `employee` in the `all`
        scope.
      properties:
        id:
          type: integer
        order_number:
          type: string
          example: ORD-20260819-ABC123
        item:
          type: object
          nullable: true
          description: null when the item row has since been deleted — the row still
            has to render, so use `item_name` for the label.
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
              description: Absolute URL.
        item_name:
          type: string
          description: Always populated ("Item" for a deleted item row).
        quantity:
          type: integer
        status:
          type: string
          enum:
          - pending_approval
          - pending
          - processing
          - fulfilled
          - cancelled
          - refunded
        status_label:
          type: string
          description: Canonical display label — "Awaiting Approval" for `pending_approval`.
        points_spent:
          type: integer
        cash_amount:
          type: number
          format: float
        total_label:
          type: string
          description: Pre-formatted money summary, e.g. "1200 pts" or "1200 pts +
            $5.00".
        payment_type:
          type: string
          enum:
          - points
          - cash
          - mixed
        variant_display:
          type: string
          nullable: true
          description: 'Selected variants as one line, e.g. "Size: L, Color: Black".
            null when the item has none.'
        has_fulfillment_error:
          type: boolean
          description: True only while a FAILED order is still `pending`. A resolved
            order reports false, so never treat this as sticky.
        placed_at:
          type: string
          format: date-time
        url:
          type: string
          description: Absolute URL of the web order page.
        employee:
          "$ref": "#/components/schemas/CompanyStorePerson"
    CompanyStorePerson:
      type: object
      nullable: true
      description: Whose order this is. Present on a list row ONLY in the `all` scope
        (in `mine` every row is the caller's, so naming them per row is noise), and
        always present on the detail payload.
      properties:
        id:
          type: integer
        name:
          type: string
        title:
          type: string
          nullable: true
        image:
          type: string
          nullable: true
    CompanyStoreOrdersPageMeta:
      type: object
      description: The page envelope every paginated Company Store endpoint answers
        with — one shape, produced by one shared helper (`Api::V1::CompanyStore::BaseController#pagination_meta`).
        Structurally identical to `CompanyStorePageMeta` in the sibling catalog spec
        file; the two should collapse into one schema once both surfaces have landed.
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
          description: Rows in the FILTERED pool (status + search), not on this page.
        total_pages:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreOrderTimelineStep:
      type: object
      description: One step of the derived lifecycle timeline.
      properties:
        key:
          type: string
          enum:
          - placed
          - awaiting_approval
          - processing
          - delivered
          - cancelled
          - refunded
        label:
          type: string
          description: Display label. The delivery step reads "Delivered" for a digital
            reward and "On its way" for anything shipped, so a gift card never promises
            a truck.
          example: On its way
        detail:
          type: string
          nullable: true
          description: The sentence beside the label — the reassurance copy, or the
            delivery estimate ("Est. 5 business days after it ships", or "Delivery
            estimate not available" when the item carries no lead time).
        state:
          type: string
          enum:
          - done
          - current
          - upcoming
          - cancelled
          - refunded
        icon:
          type: string
          description: Font Awesome class the web page paints; a native client may
            map its own.
          example: fas fa-truck
        at:
          type: string
          format: date-time
          nullable: true
          description: When the step happened. null for a step that hasn't happened
            yet, and for a legacy terminal row that never got a timestamp.
    CompanyStoreOrderDetail:
      type: object
      description: One order in full. Carries every `CompanyStoreOrderListRow` field
        plus the blocks below.
      properties:
        id:
          type: integer
        order_number:
          type: string
        item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
        item_name:
          type: string
        quantity:
          type: integer
        status:
          type: string
        status_label:
          type: string
        points_spent:
          type: integer
        cash_amount:
          type: number
          format: float
        total_label:
          type: string
        payment_type:
          type: string
          enum:
          - points
          - cash
          - mixed
        placed_at:
          type: string
          format: date-time
        url:
          type: string
        viewer:
          "$ref": "#/components/schemas/CompanyStoreOrdersViewer"
        features:
          "$ref": "#/components/schemas/CompanyStoreOrdersFeatures"
        is_mine:
          type: boolean
          description: False when a store admin is viewing someone else's order. Every
            entry in `actions` is then false — viewing is not acting.
        employee:
          "$ref": "#/components/schemas/CompanyStorePerson"
        variant_display:
          type: string
          nullable: true
        selected_variants:
          type: object
          additionalProperties:
            type: string
          description: 'The chosen variants as a map, e.g. {"size": "L"}. Empty when
            the item has none.'
        personalization:
          type: object
          nullable: true
          description: Engraving details captured at checkout for personalizable items
            (recipient_name, and optionally years_of_service / engraving_line). null
            for everything else.
          additionalProperties:
            type: string
        line_items:
          type: array
          description: Every line the order contains. A multi-item cart order has
            real rows; a single-item redemption gets ONE synthesised line, so a client
            renders the same list either way.
          items:
            type: object
            properties:
              item_id:
                type: integer
                nullable: true
              name:
                type: string
              quantity:
                type: integer
              points_price:
                type: integer
              cash_price:
                type: number
                format: float
              selected_variants:
                type: object
                additionalProperties:
                  type: string
        dates:
          type: object
          properties:
            placed_at:
              type: string
              format: date-time
            fulfilled_at:
              type: string
              format: date-time
              nullable: true
            cancelled_at:
              type: string
              format: date-time
              nullable: true
            refunded_at:
              type: string
              format: date-time
              nullable: true
        timeline:
          type: array
          description: Ordered lifecycle steps — see the endpoint description.
          items:
            "$ref": "#/components/schemas/CompanyStoreOrderTimelineStep"
        fulfillment:
          type: object
          description: The reward payload — what the employee came to this screen
            for once the order lands. Fields are null until fulfilment provides them.
          properties:
            tracking_number:
              type: string
              nullable: true
            carrier:
              type: string
              nullable: true
            tracking_url:
              type: string
              nullable: true
              description: The provider's own deep link when it supplied one, otherwise
                built from a known carrier + number. null means render the bare number.
            gift_card_code:
              type: string
              nullable: true
            reward_link:
              type: string
              nullable: true
            delivery_email:
              type: string
              nullable: true
              description: Where the redemption link was sent.
            donation_receipt:
              type: string
              nullable: true
              description: Receipt number for a charitable redemption, when the admin
                recorded one.
            has_gift_card_link:
              type: boolean
              description: True when either a code or a redemption link is available.
        shipping_address:
          type: object
          nullable: true
          description: null for anything that doesn't ship, and for a shipped order
            with no address recorded. `lines` is the same formatted block the web
            page prints, so both surfaces render one address format.
          properties:
            lines:
              type: array
              items:
                type: string
        approval:
          type: object
          nullable: true
          description: Present ONLY while the redemption is actually held — that is
            the only time "who has to approve this" is a live question.
          properties:
            required_approver:
              type: string
              enum:
              - manager
              - admin
              description: The tier stamped at checkout. A legacy hold with none reads
                `admin`.
            manager_approval:
              type: boolean
            points_held:
              type: integer
              description: The points held while the decision is pending.
        return_request:
          type: object
          nullable: true
          description: The latest return / exchange request, so a client shows "Return
            requested" instead of re-offering the button. null when there is none.
          properties:
            id:
              type: integer
            status:
              type: string
            return_type:
              type: string
              enum:
              - refund
              - exchange
            reason:
              type: string
            requested_at:
              type: string
              format: date-time
        actions:
          type: object
          description: What this viewer may actually do next, resolved against the
            same predicates the write paths enforce — including this API's own cancel
            endpoint, so a control rendered from here is one whose POST is accepted.
            `can_cancel` is offered to both people the web offers it to (the buyer,
            and a store admin on anyone's order); the other three are the BUYER's
            own acts and are false for an admin however wide their read access.
          properties:
            can_cancel:
              type: boolean
              description: True when POST /company-store/orders/{order_number}/cancel
                would be accepted for this viewer — the very same `StoreOrder#cancel_actor_for`
                reading that endpoint's gate uses.
            cancel_as:
              type: string
              nullable: true
              enum:
              - owner
              - admin
              description: Which cancel power this viewer holds. `owner` is the buyer's
                self-service cancel, `admin` a store admin acting on an order — worth
                distinguishing, because an admin cancelling somebody else's order
                deserves a different confirmation. null when they hold none.
            cancel_blocked_reason:
              type: string
              nullable: true
              description: Why cancel is unavailable while the status still looks
                cancellable — already dispatched to the reward provider, not the caller's
                order, or a cash refund needing a business administrator. null when
                the order can be cancelled, and when there is no cancel affordance
                at all (a terminal order has the return path instead).
            can_return:
              type: boolean
              description: A fulfilled PHYSICAL order with no open/approved return.
            can_report_problem:
              type: boolean
              description: A fulfilled DIGITAL order — not physically returnable,
                but it can be reported as a dud.
            can_reorder:
              type: boolean
              description: True only when the item still exists, is available, its
                category toggle is on, and at least one enabled payment path applies
                — the same four-part test the web "Order Again" button uses, so the
                deep link is never doomed.
            reorder_url:
              type: string
              nullable: true
              description: Absolute checkout URL carrying the original quantity and
                variants.
        admin:
          type: object
          description: Store admins only — absent entirely for everyone else. The
            web shows these on the admin order page; an employee gets reassurance
            copy, not a connector error string.
          properties:
            provider_order_id:
              type: string
              nullable: true
            provider_status:
              type: string
              nullable: true
            funding_source:
              type: string
              enum:
              - tenant_byo
              - platform_managed
            fulfillment_error:
              type: string
              nullable: true
              description: The connector's own failure message.
            can_retry_fulfillment:
              type: boolean
            admin_url:
              type: string
              description: Absolute URL of the web admin order page, where the decisions
                live.
    CompanyStoreOrderCancelResponse:
      type: object
      description: The receipt for the cancellation, plus the whole order re-rendered
        so the client needs no follow-up GET.
      properties:
        order:
          allOf:
          - "$ref": "#/components/schemas/CompanyStoreOrderDetail"
          description: The order as it now stands — status `cancelled`, a `cancelled`
            terminal step on the timeline, and `actions.can_cancel` withdrawn.
        cancellation:
          type: object
          description: What was just done, and to what.
          properties:
            order_number:
              type: string
              example: ORD-20260819-ABC123
            status:
              type: string
              example: cancelled
            status_label:
              type: string
              example: Cancelled
            cancelled_at:
              type: string
              format: date-time
            cancelled_by:
              allOf:
              - "$ref": "#/components/schemas/CompanyStorePerson"
              description: Whoever took the action — the buyer, or the admin who cancelled
                for them.
            actor:
              type: string
              enum:
              - owner
              - admin
              description: Which power was exercised. `owner` is the buyer's self-service
                cancel; `admin` a store admin acting on an order.
            reason:
              type: string
              nullable: true
              description: The `reason` sent with the request, as recorded. null when
                none was sent.
            points_refunded:
              type: integer
              description: Points returned to the employee's wallet — the PRE-write
                reading, so it still reports what the order held.
              example: 900
            card_refund_pending:
              type: boolean
              description: True when a captured card charge is being reversed. The
                Stripe refund runs asynchronously, so this means "a refund is on its
                way", never "the money is back".
            inventory_restored:
              type: integer
              description: Units actually returned to the item's stock — the order
                quantity, or 0 for an item with unlimited inventory, which tracks
                none.
              example: 1
        message:
          type: string
          description: 'Ready-to-show confirmation copy, phrased for whoever asked:
            the buyer is told about their own balance, an admin about the employee''s.
            Built from the same three facts the web self-cancel flash is built from.'
          example: 'Order #ORD-20260819-ABC123 was cancelled. 900 points were returned
            to your balance.'
        unread_notification_count:
          type: integer
          description: Native app badge count (the shared api/v1 envelope).
    CompanyStoreOrderCancelError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - store_disabled
              - forbidden
              - cash_reversal_forbidden
              - not_cancellable
              - cancel_failed
              - not_found
            message:
              type: string
              description: Ready-to-show copy, and the SAME sentence the web surfaces
                show for the same refusal — an admin is never told to "contact your
                store admin", because they are one.
              example: This order has already been sent to the reward provider, so
                it can't be cancelled here.
    CompanyStorePointsViewer:
      type: object
      description: Who is asking. Shared verbatim with every other Company Store endpoint.
        Reported for consistency and so a client can offer the admin surfaces — neither
        flag changes anything in this payload, which is always the caller's own wallet.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
        is_manager:
          type: boolean
          description: Does this viewer have a redemption approval queue. It does
            NOT widen the points payload; the approval queue is its own surface.
        is_store_admin:
          type: boolean
          description: A business admin/owner or a Company Store app-admin. It does
            NOT let this endpoint read someone else's wallet — that is the admin balances
            surface.
    CompanyStorePointsFeatures:
      type: object
      description: The TENANT switches. The first six are shared verbatim with the
        other Company Store endpoints; `points_expiry_enabled` is specific to this
        screen.
      properties:
        points_redemption_enabled:
          type: boolean
        cash_purchases_enabled:
          type: boolean
        mixed_payments_enabled:
          type: boolean
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
        points_per_dollar:
          type: integer
        points_expiry_enabled:
          type: boolean
          description: 'Whether the tenant expires points at all (`points_expiry_months`
            > 0). This is what distinguishes `balance.expiring_points: 0` meaning
            "nothing is close" from "this tenant does not expire points" — the second
            should render no expiry banner or countdown.'
    CompanyStorePointsBalance:
      type: object
      description: The wallet hero. The first six fields are the shared balance block
        every Company Store endpoint reports; the expiry pair is this screen's (and
        the dashboard's) addition.
      required:
      - points_balance
      - pending_points
      - lifetime_points_earned
      - lifetime_points_spent
      - expiring_points
      - expiring_within_days
      properties:
        points_balance:
          type: integer
          description: Spendable points right now. The "Available Points" tile.
          example: 2450
        pending_points:
          type: integer
          description: Earned but not yet credited — a recognition has landed and
            is awaiting release. Not spendable, so never add it to `points_balance`
            when deciding affordability.
          example: 150
        lifetime_points_earned:
          type: integer
          example: 8200
        lifetime_points_spent:
          type: integer
          description: Lifetime points redeemed. Expiries are NOT counted here — breakage
            is not a redemption.
          example: 5750
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
        expiring_points:
          type: integer
          description: What NEWLY expires within `expiring_within_days` — the figure
            the web banner claims and the expiry warning notification sends. NOT the
            total currently-expirable pool. Always 0 when `features.points_expiry_enabled`
            is false.
          example: 300
        expiring_within_days:
          type: integer
          description: The warn horizon `expiring_points` was computed over.
          example: 14
    CompanyStorePointsActivity:
      type: object
      description: The activity roll-up over the last `period_days`, from ONE grouped
        query.
      required:
      - period_days
      - earned
      - spent
      - adjustments
      - transaction_count
      properties:
        period_days:
          type: integer
          description: The window actually computed — 90 on this screen, deliberately
            NOT the dashboard widget's 30. Render the header from this rather than
            hardcoding it.
          example: 90
        earned:
          type: integer
          description: Sum of credits in the window (positive).
          example: 1200
        spent:
          type: integer
          description: Sum of debits in the window, as a POSITIVE magnitude.
          example: 800
        adjustments:
          type: integer
          description: Net of admin adjustments in the window — SIGNED, because an
            adjustment can go either way, so this may be negative.
          example: -50
        transaction_count:
          type: integer
          description: Every row in the window, of any type. Expiry and any future
            type count here without landing in `earned` or `spent`, so this is not
            necessarily the number of credits plus debits.
          example: 18
    CompanyStorePointsTrend:
      type: object
      description: The 6-month earned-vs-spent trend the web page charts.
      required:
      - months
      - has_activity
      properties:
        months:
          type: array
          description: Always 6 entries, oldest first, including zero-activity months
            so a chart renders at a stable width.
          items:
            "$ref": "#/components/schemas/CompanyStorePointsTrendMonth"
        has_activity:
          type: boolean
          description: True when any month has non-zero earned or spent. What the
            web page gates the whole card on — a flat all-zero chart is worse than
            no chart.
    CompanyStorePointsTrendMonth:
      type: object
      properties:
        month:
          type: string
          format: date
          description: First day of the month, for sorting and locale-aware formatting.
          example: '2026-08-01'
        label:
          type: string
          description: Server-rendered short label, matching the web chart's axis.
          example: Aug 2026
        earned:
          type: integer
          description: Credits in the month.
          example: 400
        spent:
          type: integer
          description: Debits in the month, as a POSITIVE magnitude. Adjustments and
            expiries appear in NEITHER series — this is the earn-vs-redeem picture,
            not a net-change chart.
          example: 250
    CompanyStorePointsTypeCounts:
      type: object
      description: ALL-TIME counts by transaction type from ONE grouped query. Every
        key is always present (0 when empty), so a filter row renders a stable set
        of pills instead of reading an absent key as zero. Deliberately NOT narrowed
        by the active `type` — see the endpoint description.
      required:
      - all
      - credit
      - debit
      - adjustment
      - expiry
      properties:
        all:
          type: integer
          description: Every row in the caller's history, of every type — the "All"
            pill. This is the WHOLE history even when the list is filtered; the filtered
            depth is `meta.total_count`.
          example: 42
        credit:
          type: integer
          example: 28
        debit:
          type: integer
          example: 11
        adjustment:
          type: integer
          example: 3
        expiry:
          type: integer
          example: 0
    CompanyStorePointsTypeFilter:
      type: object
      description: One pill in the filter row.
      properties:
        value:
          type: string
          nullable: true
          description: The `type` query value this pill applies. **null** is the All
            pill.
          enum:
          - credit
          - debit
          - adjustment
          - expiry
          -
        label:
          type: string
          description: Display label, matching the web chip and row badge ("All",
            "Credit", "Debit", "Adjustment", "Expiry").
          example: Credit
        count:
          type: integer
          description: The same number `counts` reports for this type.
          example: 28
        selected:
          type: boolean
          description: Whether this pill is the filter currently in force.
        visible:
          type: boolean
          description: Whether the web would render this pill. False only for `expiry`
            while its count is 0 and it is not selected. A client may ignore this
            and render all five.
    CompanyStorePointsTaxStatement:
      type: object
      description: The self-service tax statement link, mirroring the web header button.
      required:
      - available
      properties:
        available:
          type: boolean
          description: True only when the viewer has taxable redemptions in a year
            the statement page offers. False means do not render the link — it would
            dead-end on an empty statement.
        year:
          type: integer
          nullable: true
          description: The most recent offered year that actually HAS rows — not necessarily
            the current year. In Jan–Apr they usually differ, so link to this value
            rather than to "this year".
          example: 2025
        url:
          type: string
          nullable: true
          description: Absolute URL to the statement for `year`. null when unavailable.
    CompanyStorePointsTransaction:
      type: object
      description: One row of the points ledger.
      required:
      - id
      - transaction_type
      - type_label
      - amount
      - display_amount
      - absolute_amount
      - positive
      - balance_after
      - description
      - created_at
      properties:
        id:
          type: integer
        transaction_type:
          type: string
          enum:
          - credit
          - debit
          - adjustment
          - expiry
        type_label:
          type: string
          description: Titleized label, matching the web badge.
          example: Credit
        amount:
          type: integer
          description: SIGNED. Positive for credits, negative for debits and expiries,
            either for adjustments.
          example: -500
        display_amount:
          type: string
          description: Pre-formatted signed string, as the web prints it.
          example: "-500"
        absolute_amount:
          type: integer
          description: Magnitude, sign discarded.
          example: 500
        positive:
          type: boolean
          description: The direction of the entry. Report this rather than inferring
            from the type — an `adjustment` can go either way, which is why the web
            colours the amount on the sign.
        balance_after:
          type: integer
          description: The caller's spendable balance immediately after this entry.
          example: 1950
        description:
          type: string
          description: What happened, as the web row prints it. Falls back to a source-derived
            phrase ("Recognition received", "Store redemption") for rows stored without
            one.
          example: Redeemed Branded Hoodie
        notes:
          type: string
          nullable: true
          description: The admin's stated reason on an adjustment. The employee already
            receives it in the adjustment notification. null on every other type.
        source:
          type: object
          nullable: true
          description: A REFERENCE to the record that caused this entry, not a resolved
            label — resolving it would cost a polymorphic load per row, and the web
            rows print the type and nothing more. null for entries with no source
            (expiries, most adjustments). Fetch the record by reference if its name
            is needed.
          properties:
            type:
              type: string
              description: Model name.
              example: StoreOrder
            id:
              type: integer
              example: 8123
        created_at:
          type: string
          format: date-time
    CompanyStorePointsPageMeta:
      type: object
      description: The page envelope, identical in shape to every other paginated
        Company Store endpoint. `total_count` is the depth of the FILTERED list —
        for the whole history regardless of filter, read `counts.all`.
      required:
      - current_page
      - per_page
      - total_count
      - total_pages
      - has_next_page
      - has_prev_page
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          description: The clamped page size in force.
          example: 20
        total_count:
          type: integer
          description: Rows matching the active `type` filter.
          example: 11
        total_pages:
          type: integer
          example: 1
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreShippingCountriesResponse:
      type: object
      properties:
        shipping_countries:
          type: object
          properties:
            countries:
              type: array
              description: The countries a physical order can be shipped to, in display
                order. Never empty. Render whatever length this holds rather than
                assuming one entry.
              items:
                "$ref": "#/components/schemas/CompanyStoreShippingCountry"
            default_country:
              type: string
              description: The country to select when the caller has expressed no
                preference — always the `value` of one of the rows above, so it can
                be assigned to the field directly.
              example: United States
            prefill:
              "$ref": "#/components/schemas/CompanyStoreShippingPrefill"
        unread_notification_count:
          type: integer
          description: Piggybacked on every response in this API.
        _meta:
          type: object
          description: Piggybacked response metadata.
    CompanyStoreShippingCountry:
      type: object
      properties:
        value:
          type: string
          description: '**Submit this** as `shipping_address[country]`. The display
            NAME, not the ISO code — matching the web form and what lands in `order.shipping_address["country"]`.'
          example: United States
        code:
          type: string
          description: ISO 3166-1 alpha-2. Display and validation only — do NOT submit
            it as the country field; the write would accept it and fulfillment would
            then fail on it.
          example: US
        label:
          type: string
          description: What to show in the dropdown.
          example: United States
        states:
          type: array
          description: The country's states/provinces. `[]` for a country we hold
            no list for, which means render a free-text field.
          items:
            "$ref": "#/components/schemas/CompanyStoreShippingState"
    CompanyStoreShippingState:
      type: object
      properties:
        value:
          type: string
          description: "**Submit this** as `shipping_address[state]`. The 2-letter
            code — the opposite shape to the country field, because the Printful fulfillment
            path reads it as a `state_code`."
          example: CA
        label:
          type: string
          description: What to show in the dropdown.
          example: California
    CompanyStoreShippingPrefill:
      type: object
      description: The caller's own profile values, resolved into the shapes the two
        fields submit. The only part of this payload that varies per user.
      properties:
        country:
          type: string
          description: Always one of the offered `value`s, falling back to `default_country`
            when the profile holds none or holds one we do not ship to. A profile
            country stored as an ISO code is matched and returned as the NAME.
          example: United States
        state:
          type: string
          nullable: true
          description: The profile state normalised to its 2-letter code, accepting
            a name, a code or a lowercase code on the way in. **Null** when the profile
            holds no state or one that resolves to no known code — leave the dropdown
            unselected rather than guessing.
          example: CA
        states_required:
          type: boolean
          description: Whether the prefilled country has a state list at all. `false`
            means an empty `states` array is expected and a free-text field is correct
            — it distinguishes "no list exists" from "the list failed to load".
          example: true
    CompanyStoreShippingCountriesError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - insufficient_permissions
              - access_denied
              - store_disabled
            message:
              type: string
    CompanyStoreWatchlistResponse:
      type: object
      properties:
        watchlist:
          "$ref": "#/components/schemas/CompanyStoreWatchlistState"
        unread_notification_count:
          type: integer
          description: The caller's unread in-app notification count, piggybacked
            onto every response in this API for native badge management.
          example: 3
    CompanyStoreWatchlistState:
      type: object
      description: The watch state AFTER the write — read back from the database,
        never assumed.
      properties:
        item_id:
          type: integer
          example: 412
        item_name:
          type: string
          description: So a client can compose its own copy without holding the item
            row.
          example: Company Logo Hoodie
        kind:
          type: string
          enum:
          - wishlist
          - restock
          description: The kind this call acted on (the default `wishlist` when none
            was sent).
        watching:
          type: boolean
          description: Is the caller now watching this item for `kind`? `true` on
            every successful ADD (including an idempotent repeat), `false` on every
            successful REMOVE. It describes what is TRUE now, not what this call did
            — see `changed`.
          example: true
        changed:
          type: boolean
          description: Did THIS call move anything? `false` on an idempotent repeat
            — an add of an item already watched, or a remove of one that wasn't. Suppress
            a duplicate toast on `false`; do not treat it as a failure.
          example: true
        wishlisted:
          type: boolean
          description: Is the item on the caller's saved items? Named exactly as the
            catalog card's own field, so a cached card is patched field-for-field.
            Resolved in the SAME query as `restock_watch`.
        restock_watch:
          type: boolean
          description: Does the caller hold a back-in-stock alert for this item? Named
            as `GET /company-store/catalog/{id}` reports it. Remember it is one-shot
            — it disappears once the notification is sent.
        wishlist_total:
          type: integer
          description: 'How many saved items the caller now has — `Store::DashboardStats#wishlist_total`,
            the same figure `GET /company-store/dashboard` reports as `saved_items.total`,
            so a badge updates from the response that changed it. It is the SAVED-ITEMS
            scope: watches whose item has since been discontinued, unpublished, or
            restricted to a group this caller isn''t in are not counted, exactly as
            the dashboard grid omits them. Sent on both verbs and both kinds; a `restock`
            write leaves it unchanged.'
          example: 7
        message:
          type: string
          description: 'Ready-to-display confirmation, byte-identical to the web toggle''s
            (`StoreItemWatch.state_message`) — a heart flipping 16px is not enough
            confirmation that anything was saved. One of four strings: "Added X to
            your wishlist." / "Removed X from your wishlist." / "We''ll notify you
            when X is back in stock." / "Back-in-stock alert for X turned off."'
          example: Added Company Logo Hoodie to your wishlist.
    CompanyStoreWatchlistError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - invalid_item_id
              - access_denied
              - store_disabled
              - region_unavailable
              - region_restricted
              - not_found
              - invalid_kind
              - restock_not_applicable
            message:
              type: string
              description: Human-readable, safe to display.
            details:
              type: object
              nullable: true
              description: 'Present on the three refusals that can name what was wrong:
                `{ "item_id": "<what was sent>" }` for `invalid_item_id`, `{ "kind":
                "<what was sent>" }` for `invalid_kind`, and `{ "status": "<the item''s
                status>" }` for `restock_not_applicable`.'
    FrontlineBlockedItem:
      type: object
      description: The obligation after the flag — the same shape the item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 18
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 12
        status:
          type: string
          description: Unchanged by the flag — a blocker is an annotation, not a transition.
          example: claimed
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: false
        blocked:
          type: boolean
          description: True once flagged — the key that tells a client the flag took.
          example: true
        blocked_reason:
          type: string
          nullable: true
          description: The structured cause that was recorded.
          example: no_stock
        blocked_note:
          type: string
          nullable: true
          description: The free-text detail, if any.
          example: Fridge 2 alarm is sounding, panel reads ERR.
        blocked_at:
          type: string
          format: date-time
          nullable: true
    FrontlineBlockError:
      type: object
      description: The standard error envelope for a refused flag.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`invalid_reason`, `not_assigned`, `already_done` or `in_review`."
              example: invalid_reason
            message:
              type: string
              example: Pick what's blocking this work.
    FrontlineChecklistItemResult:
      type: object
      properties:
        item:
          type: object
          description: The obligation after the tick — the same shape the item-action
            endpoints return.
          properties:
            id:
              type: integer
              example: 18
            campaign_id:
              type: integer
              example: 42
            title:
              type: string
              example: Reset the seasonal endcap
            status:
              type: string
              example: in_progress
            location:
              type: string
              nullable: true
              example: Fourth Street
            location_id:
              type: integer
              nullable: true
              example: 12
        checklist_item_id:
          type: integer
          description: The step that was just updated.
          example: 91
        completed:
          type: boolean
          description: The step's state after this call.
          example: true
        all_completed:
          type: boolean
          description: True when every step on the task is now done — the cue to offer
            Complete.
          example: false
        task_status:
          type: string
          description: The minted checklist Task's status after the tick.
          example: in_progress
        checklist:
          type: object
          description: The full, freshly-read checklist — identical to the `checklist`
            block on GET /items/{id}.
          properties:
            total:
              type: integer
              example: 3
            completed:
              type: integer
              example: 1
            percentage:
              type: number
              format: float
              example: 33.3
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    example: 91
                  title:
                    type: string
                    example: Face and date the shelf
                  description:
                    type: string
                    nullable: true
                  sort_order:
                    type: integer
                    example: 0
                  completed:
                    type: boolean
                    example: true
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  completed_by:
                    type: string
                    nullable: true
                    example: Dana Holder
                  requires_photo:
                    type: boolean
                    example: false
                  requires_notes:
                    type: boolean
                    example: false
        warnings:
          type: array
          nullable: true
          description: Present only when the step ticked but the parent Task could
            not auto-complete.
          items:
            type: string
    FrontlineChecklistItemError:
      type: object
      description: The standard error envelope for a refused tick.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`no_checklist`, `task_finished`, `requires_notes`, `requires_photo`,
                `photo_failed` or `save_failed`."
              example: requires_photo
            message:
              type: string
              example: This checklist item requires a photo to be marked as complete.
    ClaimedItem:
      type: object
      description: The item after a successful claim — the same shape the item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`claimed` after a successful claim."
          example: claimed
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
          description: Unchanged by the claim — the obligation still falls due when
            it did.
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        claimable:
          type: boolean
          description: Always `false` on a claimed item — it is no longer in the pool.
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        blocked:
          type: boolean
          example: false
        assignment_explanation:
          type: object
          nullable: true
          description: Why the caller now holds this work (recorded from the claim
            that just happened).
    ClaimError:
      type: object
      description: The standard error envelope for a refused claim.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`already_assigned`, `not_claimable`, or a critical-cap
                code."
              example: already_assigned
            message:
              type: string
              example: Someone else just claimed this work.
    CompleteItem:
      type: object
      description: The item after completion — the same shape the other item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`done` when finished, or `submitted` when the campaign requires
            review."
          example: done
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        claimable:
          type: boolean
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
    CompleteError:
      type: object
      description: The standard error envelope for a refused completion.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`proof_required`, `already_done`, `missed`, `in_review`,
                `not_assigned`, `off_shift`, `off_site`, `location_required`, `surface_disabled`,
                `insufficient_permissions` or `not_found`."
              example: proof_required
            message:
              type: string
              example: Add a photo before marking this done.
    CoverageDashboard:
      type: object
      description: The Coverage dashboard for one node and one lens.
      properties:
        scope:
          type: object
          description: Where the viewer is, for the breadcrumb and subtitle.
          properties:
            node:
              nullable: true
              description: The current node ({id, name, level_name}); null at the
                top of the span.
            breadcrumb:
              type: array
              description: The span-clipped trail from the top of the span down to
                the current node.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  level_name:
                    type: string
                    nullable: true
            child_level_name:
              type: string
              nullable: true
              description: The tenant's own word for the child tier ("District", "Region").
            child_count:
              type: integer
            sites_reporting:
              type: integer
              description: Physical stores under the current node within the span.
        header:
          type: object
          description: The headline numbers, identical whichever lens is showing.
          properties:
            percent:
              type: integer
            tier:
              type: string
              enum:
              - good
              - warn
              - bad
              description: Completion band (≥90 good, ≥60 warn, else bad).
            total:
              type: integer
            done:
              type: integer
            in_progress:
              type: integer
            not_started:
              type: integer
            missed:
              type: integer
            blocked:
              type: integer
              description: Obligations flagged blocked (an annotation on top of their
                status).
            unassigned:
              type: integer
            sites_reporting:
              type: integer
            active_campaigns:
              type: integer
            scheduled_campaigns:
              type: integer
        view:
          type: string
          enum:
          - nodes
          - campaign
          description: The lens actually served.
        default_view:
          type: string
          enum:
          - nodes
          - campaign
        tabs:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                enum:
                - nodes
                - campaign
              label:
                type: string
                description: e.g. "By district", "By campaign".
              active:
                type: boolean
        nodes:
          type: array
          description: The child-location cards (present only when view=nodes).
          items:
            "$ref": "#/components/schemas/CoverageNodeCard"
        campaigns:
          type: array
          description: The worst-first campaign cards (present only when view=campaign).
          items:
            "$ref": "#/components/schemas/CoverageCampaignCard"
        meta:
          type: object
          description: Pagination for the campaign lens (present only when view=campaign).
    CoverageNodeCard:
      type: object
      description: A region / district / store, with its subtree rolled up. Semantic
        fields only — the client owns the colour.
      properties:
        location_id:
          type: integer
        name:
          type: string
        level_name:
          type: string
          nullable: true
        leaf:
          type: boolean
          description: True when descending stops here (link to the store's day instead).
        store_count:
          type: integer
        percent:
          type: integer
        tier:
          type: string
          enum:
          - good
          - warn
          - bad
        total:
          type: integer
        done:
          type: integer
        in_progress:
          type: integer
        not_started:
          type: integer
        missed:
          type: integer
        blocked:
          type: integer
        unassigned:
          type: integer
    CoverageCampaignCard:
      type: object
      description: One campaign's slice of the caller's span. The client composes
        the meta line from these fields.
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
        priority:
          type: string
        work_type:
          type: string
        work_type_label:
          type: string
        recurring:
          type: boolean
        starts_on:
          type: string
          format: date
          nullable: true
        ends_on:
          type: string
          format: date
          nullable: true
        sites:
          type: integer
          description: Distinct locations the campaign touches in the span.
        percent:
          type: integer
        tier:
          type: string
          enum:
          - good
          - warn
          - bad
        total:
          type: integer
        done:
          type: integer
        in_progress:
          type: integer
        awaiting_review:
          type: integer
          description: Obligations submitted and awaiting review — a SUBSET of in_progress,
            surfaced for the card's "N awaiting review" line.
        not_started:
          type: integer
        missed:
          type: integer
        blocked:
          type: integer
        unassigned:
          type: integer
        ready_to_close:
          type: boolean
          description: Active and every obligation settled (done or missed).
    CoverageNudgeResult:
      type: object
      description: The outcome of a rollup nudge, counted in managers.
      properties:
        nudged:
          type: array
          items:
            type: string
          description: The manager names who received a nudge.
        nudged_count:
          type: integer
        skipped:
          type: array
          items:
            type: string
          description: Managers skipped — already nudged today, or no reachable manager.
        message:
          type: string
          example: Nudged 2 managers. 1 skipped — already nudged today, or no reachable
            manager.
      required:
      - nudged
      - nudged_count
      - skipped
      - message
    CoverageNudgeError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - nothing_to_nudge
              - nothing_behind
              - not_found
            message:
              type: string
    CoverageRemindResult:
      type: object
      description: The outcome of a campaign-wide Remind, counted in people.
      properties:
        reminded:
          type: integer
          description: Distinct people who received at least one reminder.
        skipped:
          type: integer
          description: People who heard nothing at all — already reminded in the last
            24 hours, or no reachable recipient.
        remaining:
          type: integer
          description: Outstanding people this pass didn't reach because the 200-delivery
            cap was hit — run Remind again to reach them.
        message:
          type: string
          description: A ready-to-show sentence, identical to the web Coverage notice.
          example: Reminded 3 people. 1 didn't get one — already reminded in the last
            24 hours, or no reachable recipient.
      required:
      - reminded
      - skipped
      - remaining
      - message
    DaySheetCounts:
      type: object
      description: Exact size of all eight filters (0 included). The six status buckets
        are a partition; `everything` is their union minus `completed`; `escalated`
        overlaps `blocked`.
      properties:
        everything:
          type: integer
          example: 12
        escalated:
          type: integer
          example: 1
        blocked:
          type: integer
          example: 3
        unassigned:
          type: integer
          example: 4
        sent_back:
          type: integer
          example: 1
        in_review:
          type: integer
          example: 2
        newly_assigned:
          type: integer
          example: 2
        completed:
          type: integer
          example: 9
    DaySheetListItem:
      type: object
      description: One row of the Day Sheet — a piece of work landing on a store today.
      properties:
        id:
          type: integer
          example: 90210
        campaign_id:
          type: integer
          example: 512
        campaign:
          type: string
          nullable: true
          example: Pharmacy Cold Chain Audit
        category:
          type: object
          nullable: true
          description: The campaign's programme (category), or null.
          properties:
            id:
              type: integer
              example: 42
            name:
              type: string
              example: Compliance
        title:
          type: string
          nullable: true
          example: Fridge 2 — general stock
        work_type:
          type: string
          nullable: true
          description: task / inspection / acknowledgement.
          example: inspection
        work_type_label:
          type: string
          nullable: true
          example: Inspection
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          example: 3369
        state:
          type: string
          description: The derived Day Sheet bucket this row belongs to.
          enum:
          - blocked
          - sent_back
          - unassigned
          - in_review
          - newly_assigned
          - completed
          - other
          example: blocked
        status:
          type: string
          description: The raw lifecycle status behind the derived state.
          enum:
          - open
          - claimed
          - in_progress
          - submitted
          - done
          - reopened
          - missed
          example: in_progress
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
          example: critical
        overdue:
          type: boolean
          example: true
        escalated:
          type: boolean
          description: This blocked row's blocker group has an open escalation.
          example: false
        due_at:
          type: string
          format: date-time
          nullable: true
        handoff_count:
          type: integer
          description: How many times this work has been handed off.
          example: 1
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: false
        requires_review:
          type: boolean
          nullable: true
          example: true
        assignee:
          type: object
          nullable: true
          description: The current holder, or null for unassigned (pool) rows.
          properties:
            id:
              type: integer
              example: 771
            name:
              type: string
              example: Anthony Rivera
        blocked_reason:
          type: string
          nullable: true
          description: Present when blocked — the machine reason code.
          example: equipment_down
        blocked_reason_label:
          type: string
          nullable: true
          description: Present when blocked — the human label for the reason.
          example: Equipment is down
        blocked_note:
          type: string
          nullable: true
          description: Present when blocked — the worker's free-text note.
          example: Fridge 2 alarm is sounding, panel reads ERR.
        blocked_at:
          type: string
          format: date-time
          nullable: true
        review_note:
          type: string
          nullable: true
          description: Present when sent_back — the reviewer's send-back note.
          example: The left bay is still showing last season's card.
        reopened_at:
          type: string
          format: date-time
          nullable: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
          description: Present when in_review.
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: Present when completed.
    DaySheetListMeta:
      type: object
      description: Pagination envelope for the SELECTED filter, plus the unrecognised-filter
        and unknown-category disclosures. Note the boolean keys are `has_next_page`
        / `has_prev_page`.
      properties:
        total_count:
          type: integer
          example: 12
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present only when an unrecognised `filter` was defaulted to
            `everything`.
          example: true
        filter_note:
          type: string
          description: Human-readable note when `filter_ignored` is set.
        category_filter_ignored:
          type: boolean
          description: Present only when an unknown `category_id` was ignored.
          example: true
        category_filter_note:
          type: string
          description: Human-readable note when `category_filter_ignored` is set.
    DaySheetCategory:
      type: object
      description: One campaign category the sheet can be filtered to.
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: Planogram
        color_token:
          type: string
          nullable: true
          description: Bootstrap subtle-badge token for the category chip (primary/secondary/success/danger/warning/info/dark),
            or null.
          example: info
        active:
          type: boolean
          description: False for a retired category — still listed so a filtered historical
            sheet resolves.
          example: true
    DaySheetStore:
      type: object
      description: One store the sheet can be opened for (id + display name only).
      properties:
        id:
          type: integer
          example: 3369
        name:
          type: string
          example: Store 412 — Riverside
    DaySheetLocationsMeta:
      type: object
      description: Disclosure for the store list — count, cap state, and empty-state
        signal.
      properties:
        total:
          type: integer
          description: True store count in the caller's span (>= shown).
          example: 8
        shown:
          type: integer
          description: How many stores are returned (capped at 2,000).
          example: 8
        has_more:
          type: boolean
          description: True when the list was cut at the picker cap.
          example: false
        deactivated_only:
          type: boolean
          description: True when the span holds physical stores but every one is deactivated
            — lets a client distinguish "switched off" from "none imported".
          example: false
    ShiftBriefWindow:
      type: object
      description: The look-back window actually applied, plus the pickable options
        and snap disclosure.
      properties:
        since_hours:
          type: integer
          description: The window applied (one of the offered options).
          enum:
          - 4
          - 8
          - 12
          - 24
          - 48
          - 72
          example: 12
        since:
          type: string
          format: date-time
          description: The start of the window (now − since_hours).
        options:
          type: array
          description: The pickable windows, for the selector.
          items:
            type: integer
          example:
          - 4
          - 8
          - 12
          - 24
          - 48
          - 72
        since_hours_ignored:
          type: boolean
          description: Present only when an unrecognised `since_hours` was defaulted
            to 12.
          example: true
        since_hours_note:
          type: string
          description: Human-readable note when `since_hours_ignored` is set.
    ShiftBriefBucketSummary:
      type: object
      properties:
        count:
          type: integer
          example: 3
        capped:
          type: boolean
          description: True when the list is at bucket_limit — there may be more.
          example: false
        total:
          type: integer
          description: The true total, present only for the counted buckets (completed
            / escalated).
          example: 43
    ShiftBriefRow:
      type: object
      description: One brief row, titled by campaign name (ShiftBrief does not resolve
        work titles).
      properties:
        id:
          type: integer
          example: 1187
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          example: Pharmacy Cold Chain Audit
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 8
        status:
          type: string
          enum:
          - open
          - claimed
          - in_progress
          - submitted
          - reopened
          - done
          - missed
          example: claimed
        overdue:
          type: boolean
          example: true
        due_at:
          type: string
          format: date-time
          nullable: true
        assignee:
          type: object
          nullable: true
          description: The current holder — null for claim-pool rows.
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Anthony Rivera
    ShiftBriefBlockedRow:
      allOf:
      - "$ref": "#/components/schemas/ShiftBriefRow"
      - type: object
        description: A blocked row also carries the structured blocker reason.
        properties:
          blocked_reason:
            type: string
            nullable: true
            enum:
            - no_stock
            - missing_fixture_or_supplies
            - equipment_down
            - not_enough_time
            - unclear_instructions
            - other
          blocked_reason_label:
            type: string
            nullable: true
            example: Equipment down
          blocked_note:
            type: string
            nullable: true
            example: Fridge panel unresponsive
    ShiftBriefCompletedRow:
      allOf:
      - "$ref": "#/components/schemas/ShiftBriefRow"
      - type: object
        description: A completed row also carries WHEN it was finished.
        properties:
          completed_at:
            type: string
            format: date-time
            nullable: true
    HeldCampaignsResult:
      type: object
      properties:
        campaigns:
          type: array
          items:
            "$ref": "#/components/schemas/HeldCampaignCard"
        total_count:
          type: integer
          description: How many held campaigns are in this response.
        capped:
          type: boolean
          description: True when the count hit the server cap (more may exist).
      required:
      - campaigns
      - total_count
      - capped
    HeldCampaignCard:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          description: Always "scheduled" for a held campaign.
        priority:
          type: string
        work_type:
          type: string
        work_type_label:
          type: string
        would_launch_on:
          type: string
          format: date
          nullable: true
          description: The date it is scheduled to launch if released.
        submitted_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        submitted_at:
          type: string
          format: date-time
          description: When it entered the release gate.
      required:
      - id
      - name
      - status
      - priority
      - would_launch_on
    FrontlineItemViewer:
      type: object
      description: The caller's lens on this obligation and the actions they may take.
      properties:
        is_holder:
          type: boolean
          description: The caller currently holds this obligation.
          example: false
        is_reviewer:
          type: boolean
          description: The caller may review work at this location.
          example: true
        can:
          type: object
          description: Which of the six item actions to show for this caller — mirrors
            the web display helpers. The write endpoints re-check at the transition.
          properties:
            claim:
              type: boolean
              example: false
            complete:
              type: boolean
              example: false
            release:
              type: boolean
              example: false
            block:
              type: boolean
              example: false
            challenge:
              type: boolean
              example: false
            review:
              type: boolean
              example: true
    FrontlineItemDetail:
      type: object
      description: One obligation in full. Lens-gated sections may be absent.
      properties:
        id:
          type: integer
          example: 18
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          example: Pharmacy Cold Chain Audit
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Compliance
        title:
          type: string
          example: Pharmacy Cold Chain Audit
        work_type:
          type: string
          nullable: true
          example: task
        work_type_label:
          type: string
          nullable: true
          example: Task
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 12
        status:
          type: string
          example: in_progress
        priority:
          type: string
          example: critical
        overdue:
          type: boolean
          example: true
        due_at:
          type: string
          format: date-time
          nullable: true
        estimated_minutes:
          type: integer
          nullable: true
          example: 15
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: true
        requires_review:
          type: boolean
          nullable: true
          example: true
        blocked:
          type: boolean
          example: false
        blocked_reason:
          type: string
          nullable: true
        blocked_reason_label:
          type: string
          nullable: true
        blocked_note:
          type: string
          nullable: true
        blocked_at:
          type: string
          format: date-time
          nullable: true
        review_note:
          type: string
          nullable: true
          description: The reviewer's send-back note (present only when reopened).
        reopened_at:
          type: string
          format: date-time
          nullable: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        instruction:
          type: string
          nullable: true
          description: What HQ asked for, in full (the work's description, else the
            campaign's).
        documents:
          type: array
          description: The campaign's reference documents (expiring download URLs).
          items:
            type: object
            properties:
              id:
                type: integer
                example: 91
              filename:
                type: string
                example: cold-chain-sop-v4.pdf
              byte_size:
                type: integer
                example: 2202009
              content_type:
                type: string
                example: application/pdf
              url:
                type: string
                example: https://.../rails/active_storage/...
        holder:
          type: object
          nullable: true
          description: The current holder, or null when unclaimed.
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Priya Shah
        accountable:
          type: object
          nullable: true
          description: The accountable manager — present only when unclaimed.
          properties:
            id:
              type: integer
              example: 3
            name:
              type: string
              example: Dana Okafor
        training:
          type: object
          description: The completion training gate and any manager override.
          properties:
            requires_training:
              type: boolean
              example: false
            courses:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    example: 4
                  title:
                    type: string
                    example: Cold Chain Handling
            override:
              type: object
              nullable: true
              properties:
                granted_by:
                  type: string
                  nullable: true
                  example: Dana Okafor
                reason:
                  type: string
                  nullable: true
                granted_at:
                  type: string
                  nullable: true
        challenge:
          type: object
          nullable: true
          description: The assignment challenge in any state ("this shouldn't be mine"),
            or null.
          properties:
            status:
              type: string
              example: open
            category:
              type: string
              example: not_my_area
            category_label:
              type: string
              example: Not my area
            note:
              type: string
              nullable: true
            raised_at:
              type: string
              nullable: true
            outcome:
              type: string
              nullable: true
            reason:
              type: string
              nullable: true
            resolved_at:
              type: string
              nullable: true
        checklist:
          type: object
          description: 'The ordered steps the holder works through — present ONLY
            when the minted work is a checklist task carrying steps (absent for an
            inspection, a simple task, or an unclaimed obligation with no work yet).
            Lens-independent: both holder and reviewer see it. `total`/`completed`/`percentage`
            come from the task''s own counters.'
          properties:
            total:
              type: integer
              example: 5
            completed:
              type: integer
              example: 2
            percentage:
              type: integer
              example: 40
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    example: 310
                  title:
                    type: string
                    example: Read fridge B from the front panel
                  description:
                    type: string
                    nullable: true
                  sort_order:
                    type: integer
                    example: 0
                  completed:
                    type: boolean
                    example: true
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  completed_by:
                    type: string
                    nullable: true
                    description: Who ticked this step (null while pending).
                    example: Priya Shah
                  requires_photo:
                    type: boolean
                    example: false
                  requires_notes:
                    type: boolean
                    example: false
        assignment_history:
          type: object
          description: REVIEWER LENS ONLY. Who held it and why it moved — newest first,
            capped at 50, with the true total.
          properties:
            total:
              type: integer
              example: 3
            shown:
              type: integer
              example: 3
            has_more:
              type: boolean
              example: false
            events:
              type: array
              items:
                type: object
                properties:
                  event:
                    type: string
                    example: reassign
                  sentence:
                    type: string
                    description: The rendered one-line history sentence.
                    example: Reassigned to Priya Shah by Dana Okafor
                  note:
                    type: string
                    nullable: true
                  occurred_at:
                    type: string
                    format: date-time
                    nullable: true
        review_proof:
          description: REVIEWER LENS ONLY, and only when `status` is `submitted` —
            the inline proof the review queue renders (photos, signature, note, AI
            verdict). Same shape as the review-queue row's `proof`.
          type: object
        capture:
          type: object
          description: HOLDER / ROSTER LENS ONLY — what's already on the record.
          properties:
            captured_photos:
              type: integer
              example: 2
            signature:
              type: object
              nullable: true
              properties:
                signed:
                  type: boolean
                  example: true
                signed_by:
                  type: string
                  nullable: true
                  example: Priya Shah
                signed_at:
                  type: string
                  nullable: true
            notes_supported:
              type: boolean
              example: true
            notes:
              type: array
              items:
                type: object
                properties:
                  author:
                    type: string
                    example: Priya Shah
                  body:
                    type: string
                    example: Stock landed on the 6am truck.
                  created_at:
                    type: string
                    format: date-time
                    nullable: true
        assignment_explanation:
          type: object
          nullable: true
          description: HOLDER LENS ONLY — "why this is mine", from the basis recorded
            when the person was bound.
          properties:
            sentence:
              type: string
              example: You're the nearest active manager for Fourth Street.
            checks:
              type: string
              nullable: true
            rule:
              type: string
              nullable: true
              example: location_manager
            recorded:
              type: boolean
              example: true
    ItemNudgeResult:
      type: object
      description: The outcome of a per-item nudge.
      properties:
        nudged:
          type: boolean
          description: True when a fresh reminder was delivered; false when suppressed.
          example: true
        throttled:
          type: boolean
          description: True when nothing new was sent — already reminded in the last
            24 hours, or the recipient couldn't be reached. Always the inverse of
            `nudged`.
          example: false
        recipient:
          type: object
          description: The person the reminder addressed (the holder, else the accountable
            manager).
          properties:
            id:
              type: integer
              example: 4821
            name:
              type: string
              example: Dana Ruiz
        message:
          type: string
          description: A ready-to-show sentence describing the outcome.
          example: Reminder sent to Dana Ruiz.
        item:
          type: object
          description: The obligation the nudge was about, in the same lean shape
            the item write endpoints return.
      required:
      - nudged
      - throttled
      - recipient
      - message
      - item
    ItemNudgeError:
      type: object
      description: The standard error envelope for a refused nudge.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`no_recipient` when there is nobody to nudge."
              example: no_recipient
            message:
              type: string
              example: Nobody holds this work and no manager covers its location,
                so there's nobody to nudge.
    MyDayListItem:
      type: object
      description: One My Day obligation (Execution::CampaignItem), with the fields
        the My Day row shows. A lean, list-optimised shape — the fuller single-item
        shape (assignment explanation, challenge, block detail) is returned by the
        item action endpoints.
      properties:
        id:
          type: integer
          example: 1187
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          description: The campaign's name — one campaign lands the same work at many
            sites.
          example: Pharmacy Cold Chain Audit
        title:
          type: string
          nullable: true
          description: The work's own title, falling back to the campaign name.
          example: Pharmacy Cold Chain Audit
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 8
        status:
          type: string
          description: Lifecycle status of the obligation.
          enum:
          - open
          - claimed
          - in_progress
          - submitted
          - reopened
          - done
          - missed
          example: claimed
        priority:
          type: string
          description: The campaign's HQ priority (absent reads as normal).
          enum:
          - normal
          - important
          - critical
          example: critical
        overdue:
          type: boolean
          example: true
        due_at:
          type: string
          format: date-time
          nullable: true
        rank_reason:
          type: string
          description: The same per-row "why it's ranked here" sentence the web My
            Day shows (overdue → critical → due today → due later → important → none).
            The anti-overload cap variant ("more than your N critical") is not reproduced
            on this list — a capped-over critical still reads "Marked critical by
            HQ."
          example: Overdue since 3:14 PM.
        estimated_minutes:
          type: integer
          nullable: true
          description: The campaign's estimated duration.
          example: 15
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: true
        requires_review:
          type: boolean
          nullable: true
          example: true
        handoff_count:
          type: integer
          example: 0
        blocked:
          type: boolean
          description: Whether the worker has flagged this as "I can't do this".
          example: false
        blocked_reason:
          type: string
          nullable: true
          enum:
          - no_stock
          - missing_fixture_or_supplies
          - equipment_down
          - not_enough_time
          - unclear_instructions
          - other
        claimable:
          type: boolean
          description: True only for claim-pool rows (nobody holds them yet).
          example: false
        assignee:
          type: object
          nullable: true
          description: The current holder — null for claim-pool rows.
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Anthony Rivera
    MyDayListMeta:
      type: object
      description: Pagination envelope for the SELECTED filter, plus the unrecognised-filter
        disclosure. Note the boolean keys are `has_next_page` / `has_prev_page`.
      properties:
        total_count:
          type: integer
          example: 7
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present only when an unrecognised `filter` was defaulted to
            `assigned`.
          example: true
        filter_note:
          type: string
          description: Human-readable note when `filter_ignored` is set.
    UnifiedDayRow:
      type: object
      description: One item of the worker's day, projected from its owning app. Acted
        on in that app — this surface only ranks and links.
      properties:
        source:
          type: string
          description: Which app this row came from.
          enum:
          - frontline
          - inspections
          - must_reads
          - tasks
          - training
          - shift_offers
          - approvals
          example: frontline
        key:
          type: string
          description: Stable cross-source id, for de-dup across pages.
          example: frontline-1187
        title:
          type: string
          example: Close-out safety walk
        subtitle:
          type: string
          nullable: true
          description: Location or lane context.
          example: Store 412
        priority:
          type: string
          enum:
          - critical
          - important
          - normal
          example: normal
        overdue:
          type: boolean
          example: false
        due_at:
          type: string
          format: date-time
          nullable: true
        url:
          type: string
          nullable: true
          description: Where to act on the row, in the app that owns it. Null when
            that app resolved no route (render no link rather than a broken one).
          example: "/apps/frontline-execution/my-day"
        action_label:
          type: string
          description: The verb for the row's action.
          example: Open
        rank_reason:
          type: string
          nullable: true
          description: Why the row is ranked where it is.
          example: Due 5:00 PM.
    UnifiedDayListMeta:
      type: object
      description: Pagination envelope for the SELECTED filter, plus the unrecognised-filter
        disclosure. Note the boolean keys are `has_next_page` / `has_prev_page`.
      properties:
        total_count:
          type: integer
          example: 12
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present only when an unrecognised `filter` was defaulted to
            `all`.
          example: true
        filter_note:
          type: string
          description: Human-readable note when `filter_ignored` is set.
    FrontlineNoteItem:
      type: object
      description: The obligation after the note — the same shape the item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 18
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 12
        status:
          type: string
          example: claimed
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: false
        blocked:
          type: boolean
          example: false
    FrontlineRecordedNote:
      type: object
      description: The note that was just recorded, ready to append to the thread.
      properties:
        author:
          type: string
          description: The note's author, as it appears in the trail (full name, falling
            back to name).
          example: Dana Holder
        body:
          type: string
          description: The note text (bounded to 500 characters).
          example: Endcap reset done, but two SKUs were out of stock — flagged to
            the DM.
        created_at:
          type: string
          format: date-time
          nullable: true
          description: When the trail activity was recorded (null only if activity
            logging degraded).
    FrontlineNoteError:
      type: object
      description: The standard error envelope for a refused note.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`invalid_note`, `acknowledgement`, `unsupported_work`
                or `no_work`."
              example: invalid_note
            message:
              type: string
              example: Write the note first.
    ShiftPassdown:
      type: object
      properties:
        id:
          type: integer
          example: 42
        status:
          type: string
          enum:
          - complete
          - machine_closed
          description: |
            `complete` — a human filled it in.
            `machine_closed` — the shift ended without anyone being asked.
        machine_closed:
          type: boolean
          example: false
        location_id:
          type: integer
          example: 1118
        author:
          nullable: true
          type: object
          description: Null on a machine_closed record — that is the point of it.
          properties:
            id:
              type: integer
            name:
              type: string
        shift_ended_at:
          type: string
          format: date-time
        notes:
          type: string
          nullable: true
        structured:
          type: object
          additionalProperties:
            type: string
          example:
            headcount: '14'
            open_items: '2'
        captured_via:
          type: string
          enum:
          - day_sheet
          - wrap_up
          - kiosk
          - mobile
          - api
          description: Which surface collected it.
        acknowledged:
          type: boolean
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        acknowledged_by:
          nullable: true
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
    FrontlineQuestion:
      type: object
      description: One Q&A comment — a question or a reply.
      properties:
        id:
          type: integer
          example: 512
        body:
          type: string
          description: Plain-text body, with @mentions rendered as @Name.
          example: Do the small-format stores skip the endcap?
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 88
            name:
              type: string
              example: Dana Ruiz
        parent_id:
          type: integer
          nullable: true
          description: The question this replies to; null for a top-level question.
          example:
        created_at:
          type: string
          format: date-time
        replies:
          type: array
          description: Present on a top-level question in the GET thread; the replies
            to it, oldest first.
          items:
            "$ref": "#/components/schemas/FrontlineQuestion"
    FrontlineQuestionThread:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
              example: 42
            name:
              type: string
              example: Seasonal Endcap Reset
            status:
              type: string
              example: active
            accepting_questions:
              type: boolean
              description: Whether the campaign still takes NEW questions (false once
                closed).
              example: true
        questions:
          type: array
          items:
            "$ref": "#/components/schemas/FrontlineQuestion"
        question_count:
          type: integer
          description: The true total of top-level questions.
          example: 3
        meta:
          type: object
          properties:
            showing:
              type: integer
              example: 3
            total:
              type: integer
              example: 3
            truncated:
              type: boolean
              description: True when there are more questions than the returned slice.
              example: false
    FrontlineQuestionError:
      type: object
      description: The standard error envelope for a refused question.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`forbidden`, `invalid_parent`, `blank` or `too_long`."
              example: blank
            message:
              type: string
              example: Write your question first.
    ItemReassignResult:
      type: object
      description: The outcome of a reassign — the reloaded obligation, now with the
        new holder.
      properties:
        item:
          type: object
          description: The obligation, in the same lean shape the other item write
            endpoints return (id, campaign_id, title, location, status, assignee,
            etc.).
      required:
      - item
    ItemReassignError:
      type: object
      description: The standard error envelope for a refused reassign.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: One of `invalid_request`, `not_at_location`, `reason_required`,
                `already_assigned`, `already_done`, `in_review`, or a leave refusal.
              example: not_at_location
            message:
              type: string
              example: Dana Ruiz isn't assigned to Fourth Street.
    ReassignCandidatesResult:
      type: object
      description: The ranked store roster plus the item context and pagination meta.
      properties:
        item:
          type: object
          description: The obligation the roster is for.
          properties:
            id:
              type: integer
              example: 90123
            location_id:
              type: integer
              example: 55
            location:
              type: string
              example: Fourth Street
            current_assignee_id:
              type: integer
              nullable: true
              description: The present holder (null for an unclaimed pool item).
              example: 4821
        candidates:
          type: array
          description: The people the work can be handed to, ranked most-available
            first.
          items:
            "$ref": "#/components/schemas/ReassignCandidate"
        meta:
          type: object
          description: 'Standard pagination envelope. When the base roster hit the
            100-person cap, also carries `roster_capped: true` and a `roster_note`.'
          properties:
            total_count:
              type: integer
              example: 12
            total_pages:
              type: integer
              example: 1
            current_page:
              type: integer
              example: 1
            per_page:
              type: integer
              example: 25
            has_next_page:
              type: boolean
              example: false
            has_prev_page:
              type: boolean
              example: false
            roster_capped:
              type: boolean
              description: Present and true only when the store has more people than
                the cap.
              example: true
            roster_note:
              type: string
              description: Guidance to narrow the list with `q` when the roster was
                capped.
      required:
      - item
      - candidates
      - meta
    ReassignCandidate:
      type: object
      description: One person on the reassign roster, with the availability signals
        the picker sorts by.
      properties:
        id:
          type: integer
          example: 4830
        name:
          type: string
          example: Priya Sharma
        on_shift:
          type: boolean
          description: Whether the person is rostered at this store today.
          example: true
        open_load:
          type: integer
          description: The person's open workload (tenant-wide, due by end of day).
          example: 3
        on_leave:
          type: boolean
          description: Whether the person is on approved leave today. The reassign
            refuses them, so the picker labels it before the click.
          example: false
        is_current_assignee:
          type: boolean
          description: Whether this is the obligation's present holder.
          example: false
      required:
      - id
      - name
      - on_shift
      - open_load
      - on_leave
      - is_current_assignee
    ReleasedItem:
      type: object
      description: The item after release — the same shape the item-action endpoints
        return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`open` after a successful release."
          example: open
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
          description: Unchanged by release — the obligation still falls due when
            it did.
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        claimable:
          type: boolean
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        blocked:
          type: boolean
          example: false
        assignment_explanation:
          type: object
          nullable: true
          description: Why the (former) holder had this work; null once it is back
            in the pool.
    ReleaseError:
      type: object
      description: The standard error envelope for a refused release.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`already_done`, `in_review` or `not_assigned`."
              example: in_review
            message:
              type: string
              example: This work is waiting for review and can't be released.
    ReleaseReview:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
            priority:
              type: string
            work_type:
              type: string
            work_type_label:
              type: string
        held:
          type: boolean
          description: True when scheduled AND under publication review.
        would_launch_on:
          type: string
          format: date
          nullable: true
        sites:
          type: integer
          description: Distinct locations the campaign would target.
        obligations:
          type: integer
          description: Projected obligations — one per resolved location.
        assignment_strategy:
          type: string
          enum:
          - location_manager
          - single_assignee
          - distributed
          - claim_pool
        assignment_strategy_label:
          type: string
        proof:
          type: object
          properties:
            photo:
              type: boolean
            signature:
              type: boolean
            review:
              type: boolean
        window:
          type: object
          properties:
            starts_on:
              type: string
              format: date
              nullable: true
            ends_on:
              type: string
              format: date
              nullable: true
            eligible_from:
              type: string
              nullable: true
              description: Wall-clock start, e.g. "06:00".
            eligible_to:
              type: string
              nullable: true
        languages:
          type: object
          properties:
            list:
              type: array
              items:
                type: string
              description: Language codes the work would ship in ("en" plus each translation).
            translations:
              type: array
              items:
                type: object
                properties:
                  code:
                    type: string
                  verified:
                    type: boolean
            translations_verified:
              type: boolean
              description: True when every translation is verified and current.
        same_day:
          type: object
          properties:
            campaigns:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  status:
                    type: string
                  obligations:
                    type: integer
            total_obligations:
              type: integer
              description: This campaign's projection plus every same-day sibling's
                load.
            capped:
              type: boolean
              description: True when more same-day siblings exist than the response
                lists.
        approval:
          type: object
          properties:
            under_review:
              type: boolean
            status:
              type: string
              nullable: true
              description: The open request's status (pending/escalated).
            submitted_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
            submitted_at:
              type: string
              format: date-time
              nullable: true
            can_decide:
              type: boolean
              description: Whether THIS caller may release/hold it (engine can_approve?).
      required:
      - campaign
      - held
      - sites
      - obligations
      - proof
      - window
      - languages
      - same_day
      - approval
    PublicationDecisionResult:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
              description: The post-decision status (a hold returns a scheduled campaign
                to "draft").
        approval:
          type: object
          properties:
            status:
              type: string
              description: The request's terminal status (approved/rejected).
        released:
          type: boolean
          description: True for a release
          false for a hold.:
        message:
          type: string
          description: A ready-to-show sentence.
      required:
      - campaign
      - approval
      - released
      - message
    RequestDecisionError:
      type: object
      description: The standard error envelope for a refused intake-request decision.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`reason_required`, `already_decided`, `engine_owned` or
                `invalid`."
              example: reason_required
            message:
              type: string
              example: Say why — it's the only thing the person who asked for this
                will see.
    FrontlineRequestViewer:
      type: object
      description: The caller's lens on this intake request and the actions they may
        take.
      properties:
        is_requester:
          type: boolean
          description: The caller submitted this request.
          example: false
        is_gatekeeper:
          type: boolean
          description: The caller is in the campaign-author tier that works the queue.
          example: true
        can_approve:
          type: boolean
          description: Show the Approve control — the caller is a gatekeeper, the
            request is still pending, and no configured approval workflow owns the
            decision.
          example: true
        can_decline:
          type: boolean
          description: Show the Decline control — same condition as can_approve.
          example: true
        can_withdraw:
          type: boolean
          description: Show the Withdraw control — the caller is the submitter and
            the request is still pending.
          example: false
    FrontlineRequestDetail:
      type: object
      description: One intake request in full. Decision/campaign fields fill in as
        it moves through the funnel.
      properties:
        id:
          type: integer
          example: 31
        title:
          type: string
          example: Re-check cold chain after firmware fix
        status:
          type: string
          description: submitted / approved / declined / withdrawn.
          example: submitted
        priority:
          type: string
          example: important
        work_type:
          type: string
          nullable: true
          example: task
        work_type_label:
          type: string
          nullable: true
          example: Task
        instructions:
          type: string
          nullable: true
          example: Read both pharmacy fridges again and photograph the panel.
        note:
          type: string
          nullable: true
        estimated_duration_minutes:
          type: integer
          nullable: true
          example: 15
        desired_start_on:
          type: string
          format: date
          nullable: true
        desired_end_on:
          type: string
          format: date
          nullable: true
        desired_publish_on:
          type: string
          format: date
          nullable: true
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Compliance
        department:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 4
            name:
              type: string
              example: Pharmacy
        audience:
          type: object
          nullable: true
          description: The requested audience, or null ("Reviewer's call" on the web).
          properties:
            id:
              type: integer
              example: 12
            name:
              type: string
              example: 5 pharmacy stores
        target_location_count:
          type: integer
          description: How many stores the requester named directly (0 when they named
            an audience or left it to ops).
          example: 0
        requested_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Anthony Rivera
        requested_at:
          type: string
          format: date-time
          nullable: true
        decided_by:
          type: object
          nullable: true
          description: Who decided it — present once approved or declined.
          properties:
            id:
              type: integer
              example: 3
            name:
              type: string
              example: Dana Okafor
        decided_at:
          type: string
          format: date-time
          nullable: true
        decline_reason:
          type: string
          nullable: true
          description: The reason that travels back to the requester — present only
            on a declined request.
        under_engine_review:
          type: boolean
          description: The request is sitting in a configured approval workflow (decided
            in the Approvals surface, not the queue's buttons).
          example: false
        campaign:
          type: object
          nullable: true
          description: The draft campaign the approval minted — present only once
            approved.
          properties:
            id:
              type: integer
              example: 88
            name:
              type: string
              example: Re-check cold chain after firmware fix
            status:
              type: string
              example: draft
        attachments:
          type: array
          description: The requester's files (expiring download URLs).
          items:
            type: object
            properties:
              id:
                type: integer
                example: 91
              filename:
                type: string
                example: planogram.pdf
              byte_size:
                type: integer
                example: 220100
              content_type:
                type: string
                example: application/pdf
              url:
                type: string
                example: https://.../rails/active_storage/...
    FrontlineRequestCounts:
      type: object
      description: One badge per visible tab. `approval` is present only for a reviewer.
      properties:
        all:
          type: integer
          example: 12
        submitted:
          type: integer
          description: '"In review" — the caller''s own pending asks.'
          example: 3
        approved:
          type: integer
          example: 7
        declined:
          type: integer
          example: 2
        approval:
          type: integer
          description: Reviewer only — every pending ask in the business (the queue
            badge).
          example: 5
    FrontlineRequestListRow:
      description: One request row — the full request (as in the detail endpoint)
        plus the caller's per-row viewer.
      allOf:
      - type: object
        description: One intake request in full. Decision/campaign fields fill in
          as it moves through the funnel.
        properties:
          id:
            type: integer
            example: 31
          title:
            type: string
            example: Re-check cold chain after firmware fix
          status:
            type: string
            description: submitted / approved / declined / withdrawn.
            example: submitted
          priority:
            type: string
            example: important
          work_type:
            type: string
            nullable: true
            example: task
          work_type_label:
            type: string
            nullable: true
            example: Task
          instructions:
            type: string
            nullable: true
            example: Read both pharmacy fridges again and photograph the panel.
          note:
            type: string
            nullable: true
          estimated_duration_minutes:
            type: integer
            nullable: true
            example: 15
          desired_start_on:
            type: string
            format: date
            nullable: true
          desired_end_on:
            type: string
            format: date
            nullable: true
          desired_publish_on:
            type: string
            format: date
            nullable: true
          category:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 7
              name:
                type: string
                example: Compliance
          department:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Pharmacy
          audience:
            type: object
            nullable: true
            description: The requested audience, or null ("Reviewer's call" on the
              web).
            properties:
              id:
                type: integer
                example: 12
              name:
                type: string
                example: 5 pharmacy stores
          target_location_count:
            type: integer
            description: How many stores the requester named directly (0 when they
              named an audience or left it to ops).
            example: 0
          requested_by:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 55
              name:
                type: string
                example: Anthony Rivera
          requested_at:
            type: string
            format: date-time
            nullable: true
          decided_by:
            type: object
            nullable: true
            description: Who decided it — present once approved or declined.
            properties:
              id:
                type: integer
                example: 3
              name:
                type: string
                example: Dana Okafor
          decided_at:
            type: string
            format: date-time
            nullable: true
          decline_reason:
            type: string
            nullable: true
            description: The reason that travels back to the requester — present only
              on a declined request.
          under_engine_review:
            type: boolean
            description: The request is sitting in a configured approval workflow
              (decided in the Approvals surface, not the queue's buttons).
            example: false
          campaign:
            type: object
            nullable: true
            description: The draft campaign the approval minted — present only once
              approved.
            properties:
              id:
                type: integer
                example: 88
              name:
                type: string
                example: Re-check cold chain after firmware fix
              status:
                type: string
                example: draft
          attachments:
            type: array
            description: The requester's files (expiring download URLs).
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 91
                filename:
                  type: string
                  example: planogram.pdf
                byte_size:
                  type: integer
                  example: 220100
                content_type:
                  type: string
                  example: application/pdf
                url:
                  type: string
                  example: https://.../rails/active_storage/...
      - type: object
        properties:
          viewer:
            type: object
            description: The caller's lens on this intake request and the actions
              they may take.
            properties:
              is_requester:
                type: boolean
                description: The caller submitted this request.
                example: false
              is_gatekeeper:
                type: boolean
                description: The caller is in the campaign-author tier that works
                  the queue.
                example: true
              can_approve:
                type: boolean
                description: Show the Approve control — the caller is a gatekeeper,
                  the request is still pending, and no configured approval workflow
                  owns the decision.
                example: true
              can_decline:
                type: boolean
                description: Show the Decline control — same condition as can_approve.
                example: true
              can_withdraw:
                type: boolean
                description: Show the Withdraw control — the caller is the submitter
                  and the request is still pending.
                example: false
    FrontlineRequestListMeta:
      type: object
      description: The standard pagination envelope, plus the filter-fallback disclosure.
      properties:
        total_count:
          type: integer
          example: 12
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present and true when the requested filter was not applied
            (unknown, or `approval` for a non-reviewer).
          example: true
        filter_note:
          type: string
          description: Why the requested filter was not applied — present with filter_ignored.
          example: The `filter` value "approval" was not applied (the `approval` queue
            is only available to reviewers); showing all your requests instead.
    FrontlineRequestCreateBody:
      type: object
      description: The fields the "New request" form submits. Only `title` is required;
        `priority` defaults to `normal`.
      required:
      - title
      properties:
        title:
          type: string
          maxLength: 255
          example: Re-check cold chain after firmware fix
        instructions:
          type: string
          maxLength: 10000
          nullable: true
          description: What the floor should actually do — carried onto the campaign
            the approval mints.
          example: Read both pharmacy fridges again and photograph the panel.
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
          default: normal
          description: Defaults to `normal` when omitted (the web form pre-selects
            it).
        work_type:
          type: string
          nullable: true
          enum:
          - task
          - inspection
          - acknowledgement
          description: The kind of work asked for. Blank = "the requester didn't say,
            ops decides".
          example: task
        owning_department_id:
          type: integer
          nullable: true
          description: The requesting department (dropped to null if it isn't this
            tenant's).
        audience_id:
          type: integer
          nullable: true
          description: The requested audience (dropped to null if it isn't this tenant's).
            Null = "Reviewer's call".
        category_id:
          type: integer
          nullable: true
          description: The intake programme/category (dropped to null if it isn't
            this tenant's).
        estimated_duration_minutes:
          type: integer
          nullable: true
          minimum: 1
          maximum: 480
          description: How long one store should need (1–480 minutes).
          example: 15
        desired_start_on:
          type: string
          format: date
          nullable: true
        desired_end_on:
          type: string
          format: date
          nullable: true
          description: Must be on or after desired_start_on.
        desired_publish_on:
          type: string
          format: date
          nullable: true
        note:
          type: string
          maxLength: 2000
          nullable: true
          description: A free-text note to the reviewer.
        target_location_ids:
          type: array
          description: Stores the requester named directly. Intersected with the tenant's
            active physical sites — ids outside it are silently dropped (never fail
            the ask).
          items:
            type: integer
        store_list_paste:
          type: string
          nullable: true
          description: Pasted store numbers (one per line, or comma/tab separated).
            Resolved by external_id then exact name; the matched/unmatched split comes
            back in `store_list`.
          example: |-
            4102
            4118
            4210
    FrontlineRequestStoreListOutcome:
      type: object
      nullable: true
      description: How a pasted store list resolved. Present only when `store_list_paste`/`store_list_paste_file`
        was sent.
      properties:
        matched:
          type: integer
          description: How many pasted numbers matched a store.
          example: 2
        unmatched:
          type: array
          description: The unrecognised tokens (capped for display).
          items:
            type: string
          example:
          - '9999'
        unmatched_total:
          type: integer
          description: The true count of unrecognised tokens.
          example: 1
        has_more:
          type: boolean
          description: The unmatched list was capped for display.
          example: false
    ReviewDecisionItem:
      type: object
      description: The item after the decision — the same shape the item-action endpoints
        return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`done` after accept, `reopened` after reject."
          example: done
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
    ReviewDecisionError:
      type: object
      description: The standard error envelope for a refused decision.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`reason_required`, `not_submitted` or `self_review`."
              example: reason_required
            message:
              type: string
              example: Say what needs fixing — it's the only thing the person who
                did this work will see.
    ReviewItem:
      type: object
      description: One submitted item awaiting review, with its proof inline.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          example: Endcap reset — March
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          example: submitted
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        submitted_at:
          type: string
          format: date-time
          nullable: true
        submitted_by:
          type: object
          nullable: true
          description: The worker who submitted the proof.
          properties:
            id:
              type: integer
              example: 771
            name:
              type: string
              example: Dana Ruiz
        self_review_blocked:
          type: boolean
          description: True when this reviewer submitted the work themselves and separation
            of duties bars them from deciding it — accept/reject would be refused,
            so a client should show "someone else has to approve it".
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        proof:
          "$ref": "#/components/schemas/ReviewProof"
    ReviewProof:
      type: object
      description: The evidence the reviewer decides on.
      properties:
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        completion_note:
          type: string
          nullable: true
          example: Left bay reshot as asked.
        photos:
          "$ref": "#/components/schemas/ReviewProofPhotos"
        signature:
          type: object
          nullable: true
          description: The captured signature, or null when none was required/collected.
          properties:
            image_data:
              type: string
              description: A `data:image/...` URL of the drawn signature.
            signed_by:
              type: string
              nullable: true
              example: Dana Ruiz
        ai_check:
          type: object
          nullable: true
          description: The cached AI vision verdict on the photos, or null when AI
            is off for the business or nothing has been checked.
          properties:
            verdict:
              type: string
              example: looks_good
            looks_good:
              type: boolean
              example: true
            summary:
              type: string
              example: Shelves match the planogram; facings are full.
            checked_at:
              type: string
              format: date-time
              nullable: true
            stale:
              type: boolean
              description: True when the verdict predates the latest resubmission
                or was run against a different photo count — a client should re-run
                before trusting it.
              example: false
    ReviewProofPhotos:
      type: object
      description: The completion photos, most recent first, capped and disclosed.
      properties:
        attached:
          type: boolean
          example: true
        total:
          type: integer
          description: True photo count (>= shown.size).
          example: 8
        has_more:
          type: boolean
          description: True when the strip was cut at the 6-photo cap.
          example: true
        shown:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 55010
              filename:
                type: string
                example: endcap-left.jpg
              url:
                type: string
                description: Full-size image URL (inline).
              thumb_url:
                type: string
                description: 140px thumbnail URL.
    ReviewPaginationMeta:
      type: object
      description: The standard pagination envelope, plus the filter disclosure.
      properties:
        total_count:
          type: integer
          example: 37
        total_pages:
          type: integer
          example: 2
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: true
        has_prev_page:
          type: boolean
          example: false
        campaign_filter_ignored:
          type: boolean
          description: Present and true only when an unknown `campaign_id` was dropped.
          example: true
        campaign_filter_note:
          type: string
          description: Human-readable reason the campaign filter was not applied.
    ReadyToCloseCampaign:
      type: object
      description: One active campaign whose work is fully resolved.
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: Endcap reset — March
        status:
          type: string
          example: active
        priority:
          type: string
          nullable: true
          example: normal
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Merchandising
        work_type:
          type: string
          example: task
        work_type_label:
          type: string
          example: Task
        close_behavior:
          type: string
          description: manual / on_end_date / on_all_complete.
          example: manual
        starts_on:
          type: string
          format: date
          nullable: true
        ends_on:
          type: string
          format: date
          nullable: true
        total:
          type: integer
          description: Total obligations in the campaign.
          example: 40
        done:
          type: integer
          description: How many were signed off (done).
          example: 37
        missed:
          type: integer
          description: How many were missed (window closed).
          example: 3
    LibraryItemBookmarkState:
      type: object
      description: The caller's bookmark state for one library item AFTER the call.
        Identical for both verbs, so a client can write the result straight onto the
        item card it is holding with no field mapping.
      required:
      - library_item_id
      - bookmarked
      properties:
        library_item_id:
          type: integer
          description: The library item id (echoes the path id).
          example: 4217
        bookmarked:
          type: boolean
          description: 'The bookmark state for THIS caller after the call — always
            `true` from `POST`, always `false` from `DELETE`. Per-user: it says nothing
            about whether anyone else has saved the item.'
          example: true
        unread_notification_count:
          type: integer
          description: Piggybacked on every response in this API for native badge
            management. Unrelated to Libraries.
          example: 3
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    LibraryCard:
      type: object
      description: One row of the All Libraries index. Every field the mockup's card
        renders, with the same fallbacks the web card applies — so the two surfaces
        cannot drift on an icon, a colour or a type label.
      required:
      - id
      - title
      - description
      - image
      - banner_fill
      - icon
      - color
      - library_type
      - library_type_label
      - categories_count
      - items_count
      - updated_at
      - enabled
      - can_disable
      - link
      properties:
        id:
          type: integer
          example: 42
        title:
          type: string
          description: The library's name.
          example: Company Policies
        description:
          type: string
          nullable: true
          description: The card's blurb. `null` (never `""`) when the library has
            none.
          example: Every published company policy, grouped by the team that owns it.
        image:
          type: string
          nullable: true
          description: ABSOLUTE URL of the library's banner image, or `null` when
            it has none — in which case paint `banner_fill`. Absolute because a native
            client cannot resolve a host-relative path.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf.../banner.png
        banner_fill:
          type: string
          description: 'The CSS gradient the web card paints behind a library with
            no banner image. Sent whole rather than derived client-side: the light
            stop is a hand-picked per-preset value, so `color` alone is not enough
            to reproduce it.'
          example: linear-gradient(110deg,#3f5372,#7c8fb0)
        icon:
          type: string
          description: Font Awesome class for the mark on the banner. Falls back to
            `fas fa-book-open` — the same default the web card uses — so this is never
            blank.
          example: fas fa-file-shield
        color:
          type: string
          description: The library's hex colour, driving both the icon tint and the
            banner fill. Falls back to the app default (`#2f64b1`), so this is never
            blank and never a non-hex value.
          example: "#3f5372"
        library_type:
          type: string
          enum:
          - mixed_content
          - images_and_videos
          description: The library kind, for client logic.
          example: mixed_content
        library_type_label:
          type: string
          description: The display wording the banner prints. Provided because `titleize`
            gets it wrong — `images_and_videos` reads "Images & Videos", not "Images
            And Videos".
          example: Mixed Content
        categories_count:
          type: integer
          description: Number of categories in this library (the folder icon on the
            card). Computed by a scalar subquery, so it costs nothing per row.
          example: 6
        items_count:
          type: integer
          description: Number of items across all of this library's categories (the
            document icon on the card). Also a scalar subquery.
          example: 28
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: ISO8601. The same timestamp `sort=recent` orders on and the
            card renders as "Updated N ago".
          example: '2026-08-29T06:32:39Z'
        enabled:
          type: boolean
          description: '`false` for a disabled library — draw the "Disabled" badge
            and grey the row. Only a Libraries admin ever receives a `false` here.'
          example: true
        can_disable:
          type: boolean
          description: Whether this caller may Disable / Enable this library. App-wide,
            so identical on every row of a response and equal to the top-level `can_disable`;
            carried per-row so a client rendering one card in isolation needs no extra
            context.
          example: true
        link:
          type: string
          nullable: true
          description: ABSOLUTE web URL of the library — for the row menu's Copy link,
            and for opening the library in a webview.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/42
    LibraryListMeta:
      type: object
      description: 'The canonical six-key pagination envelope (Api::V1::BaseController#build_pagination_meta),
        identical in shape to LibraryBookmarkPagination and LibrarySearchItemPagination.
        This schema declared only the first four until 2026-09-05: the endpoint was
        swept onto the shared helper without the contract being swept with it, so
        a client generated from this spec had no `has_next_page` and stopped paging
        at page 1 — the exact defect the code change closed.'
      required:
      - total_count
      - current_page
      - total_pages
      - per_page
      - has_next_page
      - has_prev_page
      properties:
        total_count:
          type: integer
          description: Rows matching the applied filter for this caller — equal to
            the `filter_counts` entry for `active_filter`.
          example: 8
        current_page:
          type: integer
          example: 1
        total_pages:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          description: "`current_page < total_pages`. Page until this is false; do
            not infer the end from a short page."
          example: false
        has_prev_page:
          type: boolean
          example: false
    LibraryBookmarkedItem:
      type: object
      description: One saved library item — everything a row, its 3-dot sheet and
        its details panel need, from one call.
      required:
      - id
      - title
      - item_type
      - item_type_label
      - icon_type
      - icon
      - breadcrumb
      - status
      - open_mode
      - bookmarked
      properties:
        id:
          type: integer
          description: The LIBRARY ITEM id — what the bookmark write endpoints take.
          example: 412
        title:
          type: string
          example: Remote Work Policy
        description:
          type: string
          nullable: true
        item_type:
          type: string
          description: The stored item kind. Branch logic on this, not on `item_type_label`.
          enum:
          - simple_link
          - file
          - form
          - survey
          - wiki
          - post
          - image
          - video
          example: file
        item_type_label:
          type: string
          description: |-
            Display label, EXTENSION-specific where the app can tell ("PDF", "Word", "Excel") and type-level otherwise ("Link", "Form", "Wiki"). The same label the web row prints.
            NAMED `item_type_label`, NOT `type_label`, since 2026-09-05: the column is `item_type`, every other label in this repo is `<prefix>_type_label` (including `library_type_label` on the library card and `item_type_label` on `GET /libraries/{id}`), and the bare spelling meant one concept arrived under two names depending on which Libraries endpoint a client called.
          example: PDF
        icon_type:
          type: string
          description: |-
            WHAT KIND OF STRING `icon` IS — the discriminator, because the glyph cannot carry its own domain. `default` and `custom` mean `icon` is a Font Awesome CLASS; `emoji` means `icon` is a raw emoji GRAPHEME.
            Read this before you render. A client that maps `icon` to an icon font unconditionally draws tofu for every emoji row, and one that prints it as text shows "fas fa-file-pdf" for the rest. No tenant holds an emoji row today, but Load Sample Data seeds three, so this is one button press from live rather than theoretical.
            Nullable, because the column is: `library_items.icon_type` has no NOT NULL and the model's enum takes `allow_nil`, so an explicitly cleared row is a legal state. Treat null as `default`.
          enum:
          - default
          - emoji
          - custom
          -
          nullable: true
          example: default
        icon:
          type: string
          description: The glyph — a Font Awesome class or an emoji grapheme, per
            `icon_type` above. Extension-specific where known; overridden by the item's
            own icon when an admin set one.
          example: fas fa-file-pdf
        icon_color:
          type: string
          description: Hex glyph colour.
          example: "#c0392b"
        icon_background:
          type: string
          description: Fill for the plate behind the glyph. Hex, or an `rgba()` string
            when derived from an admin-chosen item colour.
          example: "#fbe7e5"
        format:
          type: string
          nullable: true
          description: Lowercase file extension without the dot, or null when the
            item has nothing to infer one from (a form, a wiki page, a bare link).
          example: pdf
        library:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Company Policies
            icon:
              type: string
              example: fas fa-shield-halved
            color:
              type: string
              example: "#3478f6"
            enabled:
              type: boolean
              description: False only for a Libraries administrator looking at a disabled
                library's saved item — grey the row and badge it "Disabled".
              example: true
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 21
            name:
              type: string
              example: Leave & Time Off
            position:
              type: integer
              example: 0
        breadcrumb:
          type: string
          nullable: true
          description: "`Library › Category`, pre-joined with the separator the design
            uses."
          example: Company Policies › Leave & Time Off
        status:
          type: string
          description: "`available` — opens normally. `inactive` — the linked record
            was unpublished/deactivated after it was linked, OR this reader may not
            open it; show the details panel, not a link. `unavailable` — the linked
            record is gone; a library manager must relink or remove it."
          enum:
          - available
          - inactive
          - unavailable
          example: available
        status_label:
          type: string
          nullable: true
          description: The badge text the web renders beside the row ("Inactive",
            "Source unavailable"), or null when the item is healthy.
          example:
        url:
          type: string
          nullable: true
          description: |-
            Absolute destination — where a tap goes. NULL whenever `open_mode` is `unavailable`, including for an item this particular reader may not open even though a link exists for someone else.
            ALSO NULL when `open_mode` is `form`: a form is opened BY ID, from `form_id`. Branch on `open_mode`, never on which of the two is set.
        form_id:
          type: integer
          nullable: true
          description: 'The id of the linked form — the locator that replaced a form
            item''s web URL, for `open_mode: form` only. Open your own form screen
            with it. Null for every other type, and null for a form this reader may
            not open (same rule `url` follows: `status` / `status_label` say why).'
          example:
        copy_link_url:
          type: string
          nullable: true
          description: 'What "Copy link" puts on the clipboard: the absolute destination.
            The same value as `url` for every type EXCEPT a form, where it is the
            card''s only URL — the clipboard''s job is to produce something a person
            can paste, and a form id pastes into nothing. Null whenever `open_mode`
            is `unavailable`.'
        link_url:
          type: string
          nullable: true
          description: The source URL an admin stored on the item, which the details
            panel prints on its own row. Distinct from `url`, which is where a tap
            goes.
        opens_in:
          type: string
          nullable: true
          description: |-
            The item's configured link target — what the web anchor's `target` reads. `GET /libraries/{id}` carries the same column as `open.target`.
            NULLABLE, and a typed client must model it as optional. `library_items.link_target` is `t.string default: "new_tab"` with no NOT NULL, the model's enum takes `allow_nil`, and the item form deliberately submits an empty value — which casts back to nil — for every item type whose "Open link in" panel is never shown, so an unanswered question stays unanswered rather than being answered "New tab" on the admin's behalf. No row holds null today; the first one would crash a client with a non-optional String here.
          enum:
          - new_tab
          - current_tab
          -
          example: new_tab
        open_mode:
          type: string
          description: How to open it, and which of `url` / `form_id` carries the
            locator. `form` — a form this app hosts; route to your own form screen
            using `form_id` (`url` is null). `external` — an off-platform http(s)
            URL; hand it to the system browser. `preview` — a file this app serves;
            open the file preview or the media viewer. `in_app` — a path inside the
            platform (a wiki page, a survey, a document record). `unavailable` — nothing
            to open; show the details panel and `status_label` says why.
          enum:
          - form
          - external
          - preview
          - in_app
          - unavailable
          example: preview
        download_url:
          type: string
          nullable: true
          description: Absolute URL for the same blob served as an attachment, or
            null when the item has no file to save (a link, a form, a wiki page, or
            a document record that stores no blob of its own). Omit the Download row
            from the sheet when null rather than showing it inert.
        media:
          type: object
          nullable: true
          description: The item's own uploaded file. Null when it has none.
          properties:
            filename:
              type: string
              example: remote-work.pdf
            content_type:
              type: string
              example: application/pdf
            byte_size:
              type: integer
              example: 1153434
            size_label:
              type: string
              description: Pre-formatted, matching what the details panel prints.
              example: 1.1 MB
            width:
              type: integer
              nullable: true
              description: |-
                Pixel width, ALWAYS an integer, and null — never zero — for a blob Active Storage has not analyzed yet.
                Integer is a coercion, not a passthrough: the two analyzers disagree on type. The image analyzer writes vips' Integer while the VIDEO analyzer writes `Float(video_stream["width"])` by design, and this key used to ship whichever the row happened to hold — 2,000 image blobs as Integer, 149 video blobs as Float, under one key. Since 2026-09-05 both are coerced, which is safe because a pixel count is a whole number in either analyzer.
              example: 2400
            height:
              type: integer
              nullable: true
              description: Pixel height. Integer on the same terms as `width`.
              example: 1600
            dimensions:
              type: string
              nullable: true
              description: '`width × height`, or null when either is unknown. Integral
                on both sides — it read "1920.0 × 1080.0" for every video until the
                coercion above landed.'
              example: 2400 × 1600
            duration_seconds:
              type: number
              nullable: true
              description: Video length. Null until background analysis has run on
                a freshly uploaded file — that is a real "not yet known", not a zero.
              example: 92.4
            duration_label:
              type: string
              nullable: true
              description: "`m:ss` or `h:mm:ss`."
              example: '1:32'
            url:
              type: string
              description: Absolute URL for the blob served INLINE (preview / viewer).
        created_by:
          type: object
          nullable: true
          description: 'Who added the item — the details panel''s "Added by" byline.
            NAMED `created_by`, NOT `added_by`, since 2026-09-05: those are the names
            the columns actually have, and the names every other payload in this namespace
            uses (`GET /libraries/{id}` for both the item and the library). `added_by`
            / `added_at` were the only two occurrences of that spelling in the whole
            `/api/v1` surface — 2 sites against 254 — so a client decoding "who added
            this, and when" needed two field names depending on the endpoint.'
          properties:
            id:
              type: integer
              example: 88
            name:
              type: string
              example: Neha Kulkarni
        created_at:
          type: string
          format: date-time
          nullable: true
          description: When the ITEM was added to the library. Not to be confused
            with `bookmark.created_at`, which is when THIS CALLER saved it — the key
            this list is ordered by.
        updated_by:
          type: object
          nullable: true
          description: Null until somebody edits the item.
          properties:
            id:
              type: integer
            name:
              type: string
        updated_at:
          type: string
          format: date-time
          nullable: true
        can_manage:
          type: boolean
          description: May this caller edit / move / delete items in this library.
            Gates the 3-dot sheet's Edit, Move and Delete rows; it follows the library's
            management level, not the caller's role alone.
          example: false
        bookmarked:
          type: boolean
          description: Always true on this endpoint. Present so one card type serves
            every list.
          example: true
        bookmark:
          type: object
          nullable: true
          description: The caller's own bookmark row.
          properties:
            id:
              type: integer
              description: The BOOKMARK id (not the item id). Useful for local ordering;
                the bookmark write endpoints address the ITEM, not this.
              example: 9021
            note:
              type: string
              nullable: true
              description: The caller's own note, authored on the web /bookmarks page.
              example: Read before the audit
            created_at:
              type: string
              format: date-time
              description: When it was saved — the sort key of this list.
    LibraryBookmarkGroup:
      type: object
      description: |-
        One library the caller has saved something in, for the grouped Saved screen's header row. Deliberately a 7-key header rather than a full LibraryCard: it carries `bookmarked_count`, which the card has nowhere to put, and a card here would ship a signed banner URL, `link`, `banner_fill` and `description` per group header on the endpoint whose other defect was over-shipping this very array.
        Sent on PAGE 1 ONLY — see `groups_total` on the response and the file header.
      required:
      - id
      - title
      - bookmarked_count
      properties:
        id:
          type: integer
          example: 7
        title:
          type: string
          description: 'The library''s name. NAMED `title`, NOT `name`, since 2026-09-05:
            every other library representation in this namespace calls it `title`
            (the shared LibraryCard behind `/libraries/list`, `/libraries/search`
            and `GET /libraries/{id}`), and this hand-built group row was the one
            place the same column came back under a different key — so a client with
            one decoded `Library` type could not read its own Saved screen. A non-optional
            `title` in Swift or Kotlin is a hard decode failure, which takes down
            the whole screen rather than one row.'
          example: Company Policies
        icon:
          type: string
          example: fas fa-shield-halved
        color:
          type: string
          example: "#3478f6"
        library_type:
          type: string
          enum:
          - mixed_content
          - images_and_videos
          example: mixed_content
        enabled:
          type: boolean
          example: true
        bookmarked_count:
          type: integer
          description: The caller's saved items in this library across the WHOLE set,
            not just this page.
          example: 1
    LibraryBookmarkPagination:
      type: object
      properties:
        total_count:
          type: integer
          description: Saved items the caller can actually see. Deleted items and
            libraries out of reach are excluded here too, so this is the number the
            "N bookmarked items" line should print.
          example: 2
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
    LibraryDetail:
      type: object
      description: The whole library screen in one payload.
      required:
      - library
      - sort
      - available_sorts
      - categories
      - meta
      properties:
        library:
          "$ref": "#/components/schemas/LibraryDetailHeader"
        sort:
          type: string
          enum:
          - default
          - az
          - recent
          description: The order that was APPLIED, always a canonical key — so a client
            that sent the `updated` alias, or an unrecognised value, can tell what
            it actually got.
          example: default
        available_sorts:
          type: array
          description: The orders this endpoint accepts, for the order dropdown.
          items:
            type: string
          example:
          - default
          - az
          - recent
        categories:
          type: array
          description: The library's categories, in the order the applied `sort` puts
            them (position order under `default`). Empty categories are INCLUDED,
            so the response never hides structure. Empty array for a library with
            no categories, and for a `category_id` that is not in this library.
          items:
            "$ref": "#/components/schemas/LibraryDetailCategory"
        meta:
          "$ref": "#/components/schemas/LibraryDetailMeta"
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    LibraryDetailHeader:
      type: object
      description: |-
        The library card `/libraries/list` returns, plus the settings and capability flags only the detail screen needs. Field names and fallbacks are shared with that endpoint, so the two surfaces cannot drift on a name, a cover, a mark or a type label.
        `visibility`, `management_level` and `created_by` are MANAGER-ONLY and are omitted entirely — not nulled — when `can_manage_library` is false. They are correspondingly absent from `required` below.
      required:
      - id
      - title
      - description
      - image
      - banner_fill
      - icon
      - color
      - library_type
      - library_type_label
      - categories_count
      - items_count
      - enabled
      - can_disable
      - can_manage_items
      - can_manage_library
      - can_reorder_categories
      properties:
        id:
          type: integer
          example: 312
        title:
          type: string
          example: Company Policies
        description:
          type:
          - string
          - 'null'
          description: Null rather than an empty string when unset.
          example: Everything HR publishes
        image:
          type:
          - string
          - 'null'
          description: Absolute URL of the library's banner image, or null when none
            is attached — in which case a client paints `banner_fill`.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/banner.png
        banner_fill:
          type: string
          description: The exact CSS gradient the web hero paints behind a library
            with no banner image. Sent whole because its light stop is a hand-picked
            per-preset value — a client could NOT derive it from `color`.
          example: linear-gradient(110deg,#3f5372,#7c8fb0)
        icon:
          type: string
          description: Font Awesome class. Defaults to `fas fa-book-open`.
          example: fas fa-file-shield
        color:
          type: string
          description: Hex colour, defaulted through the same helper the web card
            uses.
          example: "#3f5372"
        library_type:
          type: string
          enum:
          - mixed_content
          - images_and_videos
        library_type_label:
          type: string
          description: The product wording, which `titleize` gets wrong — "Images
            & Videos", not "Images And Videos".
          example: Mixed Content
        visibility:
          type: string
          enum:
          - all_users
          - specific_audiences
          description: |-
            MANAGER-ONLY. The key is ABSENT — not null — unless `can_manage_library` below is true.
            `visibility` and `management_level` are the library's ACCESS RULES, and they shipped to every reader until 2026-09-04. Together they hand a plain member a map of which libraries in the tenant are restricted and, worse, exactly which carry `management_level: anyone` — i.e. where item writes are open to every reader. Neither is rendered to a non-manager on the web either; both live in the library form, behind the same predicate.
            A manager who receives them may not necessarily EDIT them: that is a second, stricter predicate (`SpacesController#library_audience_editable?` — admin, app-admin, or the library's creator), and it has no flag in this payload yet, so a client must not infer write access from the key's presence.
        management_level:
          type: string
          enum:
          - anyone
          - admins_and_specific
          - admins_only
          - domain_admins_only
          description: MANAGER-ONLY, on exactly the same terms as `visibility` above
            — absent unless `can_manage_library` is true, and read-only for some of
            the managers who receive it.
        default_view_mode:
          type: string
          enum:
          - grid
          - large
          - compact
          description: The library's own default, which SEEDS a new category's layout
            and does not override an existing one. Every category below carries the
            `view` it was actually given; read that, not this, to render.
        show_category_icons:
          type: boolean
          description: When false, a client omits the category heading's icon entirely.
        show_item_icons:
          type: boolean
          description: When false, a client omits the per-item mark entirely.
        categories_count:
          type: integer
          description: |-
            The library's TRUE number of categories — the same value `/libraries/list` and `/libraries/search` return on the shared LibraryCard, and unaffected by `?category_id=`.
            It used to be overridden here with a count of what the response happened to carry, which made a deep link report that the library held almost nothing: measured 2026-09-05 on a library truly holding 3 categories / 180 items, `/list` said 3 and 180, `GET /{id}` said 3 and 180, and `GET /{id}?category_id=…` said **1 and 60** — same library, same field, same session. A client that cached the card from the list and refreshed it from a deep link overwrote a correct total with a scoped one and had no field left to recover the real number from.
            The response-scoped pair still exists, under names that say so: `meta.categories_count` / `meta.items_count`.
          example: 6
        items_count:
          type: integer
          description: The library's TRUE number of items across every category, on
            the same terms as `categories_count` above. Not the length of anything
            in this payload — that is `meta.items_count`.
          example: 28
        created_by:
          allOf:
          - "$ref": "#/components/schemas/LibraryUserRef"
          description: |-
            MANAGER-ONLY. The key is ABSENT — not null — unless `can_manage_library` below is true, on exactly the same terms as `visibility` and `management_level` above.
            WHO CREATED A LIBRARY IS AN AUTHORIZATION FACT here, not provenance: `libraries.created_by_id` is read by `Libraries::Access#can_manage_library?` and by the stricter `SpacesController#library_audience_editable?`, so this field names, per library, the one person outside the admin tier who can rewrite that library's audience. It moved into `DetailsController#manager_only_audience_payload` on 2026-09-05, alongside the two audience fields that moved there on 2026-09-04.
            This schema still listed it as an unconditional field until 2026-09-06, so a spec-generated client bound a byline to a key a plain reader never receives. (The item-level `created_by` in `LibraryItem` further down is a different field — a plain byline, sent to every reader.)
        created_at:
          type:
          - string
          - 'null'
          format: date-time
        updated_at:
          type:
          - string
          - 'null'
          format: date-time
        enabled:
          type: boolean
          description: False for a disabled library. Only a caller who can manage
            the library reaches this payload at all in that state (everyone else 404s),
            so `false` here means "badge it Disabled and offer Enable".
        can_disable:
          type: boolean
          description: Whether this caller may flip the library's enabled state. App-wide
            rather than per-library, and the same authority `POST /libraries/{id}/disable`
            gates on.
        can_manage_items:
          type: boolean
          description: Gates Add Item and the item kebab's Edit / Move / Delete. True
            for a Libraries admin, for anyone who can view an `anyone`-managed library,
            and for a contributor or manager under `admins_and_specific`.
        can_manage_library:
          type: boolean
          description: Gates Add Category, Edit library and Reorder categories. True
            for a Libraries admin and for a named `manager`.
        can_reorder_categories:
          type: boolean
          description: |-
            Offered only when the response is actually SHOWING the order those stored positions control: `can_manage_library` AND `sort` is `default` AND there is more than one category. Under `az` or `recent` the admin would be arranging rows whose new order the screen in front of them never reflects.
            THERE IS NO REORDER ENDPOINT IN THIS NAMESPACE. This flag answers "may you"; `link` below answers "where" — the Reorder categories dialog is a modal on the library page, so the page is its address. No separate `manage_url` is minted at this level because it would hold the identical string. See `LibraryItemActions`.
        link:
          type:
          - string
          - 'null'
          description: Absolute web URL for the library — for Copy link, for a webview
            hand-off, and the destination for `can_reorder_categories` / `can_manage_library`
            above, whose actions (Reorder categories, Add category, Edit library,
            Add item) all live on that page and have no endpoint here.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/312
    LibraryDetailCategory:
      type: object
      description: One category block. `view` and `sort_order` are the admin-owned
        columns that decide how this section renders and sorts for EVERY reader.
      required:
      - id
      - name
      - icon
      - position
      - view
      - view_label
      - sort_order
      - items_sorted_by
      - items_count
      - items_truncated
      - can_reorder_items
      - manage_url
      - items
      properties:
        id:
          type: integer
          example: 908
        name:
          type: string
          example: Site Procedures
        icon:
          type: string
          description: Font Awesome class. Defaults to `fas fa-folder`.
          example: fas fa-clipboard-check
        position:
          type: integer
          description: The category's stored order within the library.
        view:
          type: string
          enum:
          - compact
          - large
          - grid
          description: The layout this category renders in — `compact` (title-only
            rows), `large` (rows with description and meta line) or `grid` (thumbnail
            tiles). Set by an admin on the category and identical for every reader;
            there is no per-user view switcher.
        view_label:
          type: string
          description: The product wording for `view`, as the category form offers
            it.
          example: Large
        sort_order:
          type: string
          enum:
          - admin_defined
          - a_to_z
          - z_to_a
          description: The category's OWN ordering. `admin_defined` is the hand-ordered
            sequence the Reorder dialog writes to each item's `position`. This is
            what `sort=default` honours per category.
        items_sorted_by:
          type: string
          enum:
          - admin_defined
          - a_to_z
          - z_to_a
          - az
          - recent
          description: The rule that actually produced the `items` array below — this
            category's `sort_order` under `sort=default`, otherwise the page-wide
            override. Without it a client showing "Sorted A→Z" per section could not
            tell whether that was the reader's choice or the admin's.
        items_count:
          type: integer
          description: The category's TRUE item total — NOT the length of `items`,
            which is capped at 500 (`MAX_ITEMS_PER_CATEGORY`). The two are equal unless
            `items_truncated` is true. Zero for an empty category, which is still
            returned.
        items_truncated:
          type: boolean
          description: |-
            True when this category holds more than 500 items and `items` below therefore carries only the first 500 in the applied order — `items_count` keeps the real number.
            The cap exists so one GET cannot be an unbounded read of a whole library; it is far above anything the product produces (measured across every tenant 2026-09-04: the largest category holds 60 items), so it truncates nothing that exists today. Read the flag anyway — the rule here is that truncation is REPORTED, never silent, because a client quietly missing half a category is a worse bug than the unbounded read the cap replaced.
          example: false
        can_reorder_items:
          type: boolean
          description: |-
            Offered only when the response is showing the hand-ordered sequence: `can_manage_items` AND `sort` is `default` AND this category's `sort_order` is `admin_defined` AND it holds more than one item. The same four-part gate the web kebab applies.
            THERE IS NO REORDER ENDPOINT IN THIS NAMESPACE — this flag answers "may you", and `manage_url` below answers "where". See that field.
        manage_url:
          type:
          - string
          - 'null'
          description: |-
            Absolute web URL for this category's block on the library page — where its kebab (Edit category, Reorder items) lives. The dialog `can_reorder_items` refers to is a modal rendered into that page rather than a screen with an address of its own, so this is the library page anchored at `#category-{id}`.
            Null unless the caller can manage this library or its items — the same predicates the web page applies, so the URL is never a page its recipient would be bounced off. The KEY is always present; read the null, do not test for the key.
            Hand it to a web view or the system browser. See `LibraryItemActions` for why the management flags in this response ship URLs instead of API verbs.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/312#category-908
        items:
          type: array
          items:
            "$ref": "#/components/schemas/LibraryDetailItem"
    LibraryDetailItem:
      type: object
      description: 'One item, carrying everything the mockup''s three surfaces read:
        the row or tile, the ⋯ actions sheet, and the "View details" drawer that sheet
        opens.'
      required:
      - id
      - title
      - description
      - position
      - item_type
      - item_type_label
      - display_label
      - icon
      - icon_color
      - icon_background
      - status
      - bookmarked
      - manage_url
      - actions
      properties:
        id:
          type: integer
          example: 4217
        title:
          type: string
          example: Working at Height — Safety Harness Procedure
        description:
          type:
          - string
          - 'null'
          description: Null rather than an empty string when unset. Rendered by the
            `large` layout.
        position:
          type: integer
          description: The item's stored order within its category.
        item_type:
          type: string
          enum:
          - simple_link
          - file
          - form
          - survey
          - wiki
          - post
          - image
          - video
        item_type_label:
          type: string
          description: The human word for `item_type`.
          example: File
        display_label:
          type: string
          description: 'What the row''s plate and the tile''s panel actually PRINT:
            the FORMAT where it can be told ("PDF", "Excel", "Word"), the type otherwise
            ("Link", "Form"). On a phone row the format is the useful signal, not
            the generic word "File".'
          example: PDF
        icon:
          type: string
          description: Font Awesome class, resolved by the same helper the web listing
            uses — format-specific where possible, and overridden by the item's own
            icon when an admin set one.
          example: fas fa-file-pdf
        icon_color:
          type: string
          description: Hex tint for the glyph. The item's own `icon_color` wins when
            set.
          example: "#c0392b"
        icon_background:
          type: string
          description: Fill for the plate behind the glyph. Hex from the type/format
            palette, or an `rgba()` wash of the item's own colour when one is set.
          example: "#fbe7e5"
        thumbnail_url:
          type:
          - string
          - 'null'
          description: Absolute URL of the picture to show instead of the plate. Present
            for an IMAGE item with a real raster attached; null for everything else
            — no page rendering or video poster is invented for a document or a video.
        library_id:
          type: integer
        library_name:
          type: string
        category_id:
          type: integer
        category_name:
          type: string
        breadcrumb:
          type: string
          description: '"Library › Category" — the actions sheet''s header line, pre-composed
            so a client does not re-join it.'
          example: Company Policies › Site Procedures
        status:
          type: string
          enum:
          - available
          - inactive
          - unavailable
          description: The state of the record this item points at. `inactive` — the
            linked form / survey / wiki page exists but is not in its openable state
            (unpublished, draft, deactivated). `unavailable` — the linked record is
            gone. `available` for a healthy item and for any item with nothing linked.
        status_label:
          type:
          - string
          - 'null'
          description: The badge text for a bad state ("Inactive", "Source unavailable"),
            null when `available`.
        status_message:
          type:
          - string
          - 'null'
          description: The details drawer's own explanatory sentence for a bad state,
            so a client does not re-word it. Null when `available`.
        created_by:
          "$ref": "#/components/schemas/LibraryUserRef"
        created_at:
          type:
          - string
          - 'null'
          format: date-time
          description: The drawer's "Added by … · date" row.
        updated_by:
          "$ref": "#/components/schemas/LibraryUserRef"
        updated_at:
          type:
          - string
          - 'null'
          format: date-time
        file:
          oneOf:
          - "$ref": "#/components/schemas/LibraryItemFile"
          - type: 'null'
          description: 'The uploaded blob''s own description — the drawer''s Format
            / Size / Dimensions / Duration rows. NULL for anything with no blob of
            its own: a link, a form, a wiki item, and a `file` item that points at
            an HR File Manager record rather than carrying an upload (reaching that
            record''s metadata means walking a version chain once per row, which is
            an N+1 a list endpoint must not ship — such an item still carries `open`,
            which is where the document is served from).'
        link_url:
          type:
          - string
          - 'null'
          description: The item's stored source URL — the drawer's "URL" row. For
            a simple link this is the same as `open.url`; for other types it is the
            raw field an admin typed. Null when unset.
        open:
          oneOf:
          - "$ref": "#/components/schemas/LibraryItemOpen"
          - type: 'null'
          description: Where and how the item opens, or NULL when there is nowhere
            to go — a dangling linked record, an unattached media item, a non-http
            link, or a record this caller may not reach. A client must treat null
            as "render the title as plain text", never as an error; that is exactly
            what the web listing does.
        copy_link_url:
          type:
          - string
          - 'null'
          description: |-
            What the kebab's "Copy link" puts on the clipboard: the destination made ABSOLUTE, because a copied link is pasted elsewhere and a host-less path is broken the moment it lands. Null whenever `open` is.
            This stays a URL for a FORM item, whose `open` block carries only a `form_id` — the clipboard's job is to produce something a person can paste, and an id pastes into nothing.
        download:
          oneOf:
          - "$ref": "#/components/schemas/LibraryItemDownload"
          - type: 'null'
          description: The item's own blob, served with an attachment disposition
            — the same blob `open` shows, saved rather than displayed. Null when the
            item carries no upload.
        bookmarked:
          type: boolean
          description: Whether THIS caller has saved the item. Per-user, and the same
            platform bookmark `POST`/`DELETE /libraries/items/{id}/bookmark` writes,
            so the flag and those verbs cannot disagree.
        manage_url:
          type:
          - string
          - 'null'
          description: |-
            Absolute web URL for this item's edit form — the destination for BOTH `actions.edit` and `actions.move`, which are one screen: the form carries the Category select, so moving an item is an edit of its category rather than a second page.
            NOT a second copy of `open` / `copy_link_url`. Those point at the item's DESTINATION — the wiki page, the form, the blob — while this points at the Libraries management UI. A client that acted on `actions.edit` previously had nothing in the response to navigate to.
            Null unless `actions.edit` is true (the same `can_manage_items` predicate the web controller applies), so it is never a page its recipient would be bounced off. The KEY is always present; read the null, do not test for the key.
            Hand it to a web view or the system browser. See `LibraryItemActions` for why.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/312/items/4471/edit
        actions:
          "$ref": "#/components/schemas/LibraryItemActions"
    LibraryItemFile:
      type: object
      description: The uploaded blob's format, size and media properties.
      properties:
        filename:
          type: string
          example: harness-procedure.pdf
        extension:
          type:
          - string
          - 'null'
          description: Lowercase, no dot. Null for a format the style table has no
            entry for.
          example: pdf
        format_label:
          type: string
          description: The human word for the format, the same one the tile's plate
            prints.
          example: PDF
        content_type:
          type: string
          example: application/pdf
        byte_size:
          type: integer
          example: 1258291
        size_label:
          type: string
          description: Human-readable size, pre-formatted.
          example: 1.2 MB
        width:
          type:
          - integer
          - 'null'
          description: From the Active Storage analyzer. NULL — not zero — for a blob
            that has not been analyzed yet, so a client renders nothing rather than
            "0 × 0".
          example: 2400
        height:
          type:
          - integer
          - 'null'
          example: 1600
        dimensions:
          type:
          - string
          - 'null'
          description: Pre-composed "W × H", null unless both are known.
          example: 2400 × 1600
        duration_seconds:
          type:
          - number
          - 'null'
          description: Video / audio only, from the analyzer.
          example: 198.4
        duration_label:
          type:
          - string
          - 'null'
          description: "`duration_seconds` as m:ss."
          example: '3:18'
    LibraryItemOpen:
      type: object
      description: |-
        The resolved destination and how a client should present it.
        TWO SHAPES, told apart by `mode`. A `form` item is opened BY ID: it carries `form_id` and NO `url` / `path`, because a client routes to its own form screen and a web path it would have to parse was useless to it. Everything else carries `url` + `path` and no `form_id`. Branch on `mode`, never on which key happens to be present.
        A form's pasteable web URL is still available — as the item's `copy_link_url`, whose job is to produce something a person can paste.
      required:
      - mode
      properties:
        url:
          type: string
          description: 'Absolute destination URL. ABSENT for `mode: form` — use `form_id`.'
        path:
          type: string
          description: 'The same destination as this app resolved it — a path for
            anything served in-app, an absolute URL for an external link. Provided
            so a native client can route in-app destinations itself instead of re-parsing
            `url`. ABSENT for `mode: form`.'
        form_id:
          type: integer
          description: 'The id of the linked form, for `mode: form` only — the locator
            that replaced this item''s form URL. Open your own form screen with it.
            Absent for every other mode.'
          example: 583
        target:
          type:
          - string
          - 'null'
          enum:
          - new_tab
          - current_tab
          -
          description: |-
            The admin's own choice on the item, which the web anchor's `target` reads. The same column `/search` and `/bookmarks` return as `opens_in`.
            NULLABLE, and a typed client must model it as optional. `library_items.link_target` is `t.string default: "new_tab"` with no NOT NULL, the model's enum takes `allow_nil`, and the item form deliberately submits an empty value — which casts back to nil — for every item type whose "Open link in" panel is never shown, so an unanswered question stays unanswered rather than being answered "New tab" on the admin's behalf. No row holds null today; the first one would crash a client with a non-optional String here.
        mode:
          type: string
          enum:
          - form
          - preview
          - external
          - in_app
          description: |-
            Which presenter to use, without parsing the URL. `form` — a form this app hosts; route to your form screen using `form_id` (there is no `url`). `preview` — an image or video item with an attached blob, for the file preview / full-screen media viewer. `external` — an absolute URL that belongs in the system browser. `in_app` — a path this app serves itself.
            `preview`, NOT `media`, since 2026-09-05. The card serializer behind `/search` and `/bookmarks` has always spelled this state `preview` in its own `open_mode`; this was the one token of the four where the two surfaces disagreed, so a client switching on the mode string mis-routed an uploaded file depending on which endpoint the item came from.
            There is no `unavailable` member here, and that is not an omission: where the card's `open_mode` says `unavailable`, this endpoint sends `open: null` instead. Treat a null `open` as "render the title as plain text" and read `status` / `status_message` for why.
            One caveat while the two implementations converge: `preview` is narrower here than on the card. This endpoint requires an `image` or `video` item, so a `file` item carrying its own uploaded PDF answers `in_app` here and `preview` from `/bookmarks` and `/search`. Either way the blob is served by this app — route on `mode` per endpoint, and use `download` / `file` to decide whether there is something to preview at all.
        description:
          type: string
          description: The actions sheet's sub-label under "Open" — per type, and
            naming the bad state first when there is one ("Inactive — opens details").
          example: Opens the file preview
    LibraryItemDownload:
      type: object
      required:
      - url
      - filename
      properties:
        url:
          type: string
          description: Absolute blob URL with an attachment disposition.
        filename:
          type: string
          example: harness-procedure.pdf
        format_label:
          type: string
          example: PDF
    LibraryItemActions:
      type: object
      description: |-
        The ⋯ kebab's option set, ALREADY GATED — so a client renders the menu without re-implementing a single permission rule. Everything above the divider belongs to any viewer; `edit`, `move` and `delete` need `can_manage_items`.
        "Reorder items" is deliberately NOT here: it reorders a CATEGORY, so it is answered once as `can_reorder_items` on the category rather than repeated identically on every item in it.
        ### Which of these has an API endpoint, and where the rest go
        This namespace is a READ API with two writes. The complete `/api/v1/libraries` route set is nine entries: `GET /libraries/list`, `GET /libraries/search`, `GET /libraries/bookmarks`, `POST` and `DELETE /libraries/items/{id}/bookmark`, `POST /libraries/{id}/disable`, `POST /libraries/{id}/enable`, `DELETE /libraries/{library_id}/items/{id}`, and this `GET /libraries/{id}`. There is no `PATCH` or `PUT` anywhere, no item-create `POST`, no move and no reorder.
        So of the eight flags below:
        * `bookmark` and `delete` have a verb —
          `POST`/`DELETE /libraries/items/{id}/bookmark` and
          `DELETE /libraries/{library_id}/items/{id}`.
        * `open`, `copy_link`, `download` and `details` need none: they are
          answered by `open`, `copy_link_url`, `download` and this payload
          itself.
        * **`edit` and `move` have none.** They are true, correctly-gated
          answers to "may this caller do it", and the action is performed on the
          web — so the item ships `manage_url`, the absolute URL of the form
          that performs both. The flag says whether to draw the row;
          `manage_url` says where the tap goes. Hand it to a web view or the
          system browser.

        The same split applies to the two `can_reorder_*` flags: neither has an endpoint, `can_reorder_items` hands off through the category's `manage_url`, and `can_reorder_categories` through the library's `link`.
        Do NOT read `edit: true` as "there is a PATCH for this". If write endpoints are added later these flags keep their meaning and `manage_url` stays — a URL to the full management screen is worth having next to a narrow write verb — and this section is what will change.
      required:
      - open
      - bookmark
      - details
      - copy_link
      - download
      - edit
      - move
      - delete
      properties:
        open:
          type: boolean
          description: False when `open` is null — there is nowhere to go.
        bookmark:
          type: boolean
          description: Always true. The toggle is per-user and every viewer holds
            it.
        details:
          type: boolean
          description: Always true — the drawer renders from this very payload, so
            it needs no further call.
        copy_link:
          type: boolean
          description: False when there is no destination to copy.
        download:
          type: boolean
          description: True only when the item carries its own uploaded blob.
        edit:
          type: boolean
          description: Needs `can_manage_items`. NO API endpoint — performed at the
            item's `manage_url`, which is non-null exactly when this is true.
        move:
          type: boolean
          description: |-
            "Move to category…". Needs `can_manage_items` AND somewhere else to move to — asked of the LIBRARY, not of the request, so it stays true under `?category_id=` scoping.
            NO API endpoint, and not a separate screen either: it is performed at the item's `manage_url`, the same edit form as `edit`, whose Category select is the move.
        delete:
          type: boolean
          description: Needs `can_manage_items`. The one management flag here that
            DOES have an endpoint — `DELETE /libraries/{library_id}/items/{id}`.
    LibraryDetailMeta:
      type: object
      description: Facts about THIS RESPONSE, as opposed to about the library. The
        counts here are response-scoped and narrow with `?category_id=`; their same-named
        twins on `library` are the library's true totals and do not. Print whichever
        the surface is actually describing — and never assume the two agree, because
        under a category scope they deliberately do not.
      required:
      - categories_count
      - items_count
      - categories_truncated
      properties:
        categories_count:
          type: integer
          description: The number of categories this response carries — so a client
            never prints a total the payload does not contain. Narrows with `?category_id=`.
            The library's own total is `library.categories_count`.
        items_count:
          type: integer
          description: |-
            The number of items this response carries, across every category. Response-scoped on the same terms; the library's own total is `library.items_count`.
            Counted per category at no more than `MAX_ITEMS_PER_CATEGORY` (500), so it can never exceed the number of item objects actually serialized even when a category is truncated. Where a category's own `items_count` is its TRUE total and may run ahead of its `items` array, this one deliberately does not — the two answer different questions. No library on any tenant reaches the cap today, so the distinction is dormant.
        categories_truncated:
          type: boolean
          description: |-
            True when the library holds more categories than this response carries — `categories` is capped at 100 (`MAX_CATEGORIES`), and this is what lets a client tell "this library has 100 categories" from "I was given the first 100 of more".
            Never silently partial: the same contract as `items_truncated` on each category and as `libraries_truncated` / `categories_truncated` on `GET /libraries/search`. The cap is 33× the largest library measured on any tenant (3 categories), so it truncates nothing that exists today — read the flag rather than the measurement.
          example: false
        scoped_to_category_id:
          type:
          - integer
          - 'null'
          description: Echoes `?category_id=` when it was applied, null otherwise
            — so a client can tell "this library has one category" from "I asked for
            one category".
    LibraryUserRef:
      type:
      - object
      - 'null'
      description: A minimal person reference for a byline.
      properties:
        id:
          type: integer
        name:
          type: string
          example: Neha Kulkarni
    LibrarySearchCounts:
      type: object
      description: |-
        Totals per record type, counted over the WHOLE match set rather than the returned page — so a client can label its groups ("Items 17") without pagination making the label lie.
        PRESENT AND COMPLETE ON EVERY PAGE, computed off the match relations rather than off the arrays beside it. That is what makes it the answer to "is `libraries: []` on page 2 an empty result, or a list I already have?" — and it is also what `libraries_truncated` / `categories_truncated` are derived from. Their sum is `counts_total`.
      required:
      - libraries
      - categories
      - items
      properties:
        libraries:
          type: integer
          example: 1
        categories:
          type: integer
          example: 1
        items:
          type: integer
          example: 16
    LibrarySearchItemPagination:
      type: object
      description: Describes the ITEM page — the only paginated list in this response.
        A dedicated schema rather than the shared ResponseMeta, which is the shape
        of the piggybacked `_meta` envelope (request_id / generated_at / execution_time_ms)
        and none of whose fields appear here. Same arrangement as the two sibling
        endpoints (LibraryListMeta, LibraryBookmarkPagination).
      required:
      - total_count
      - current_page
      - total_pages
      - per_page
      - has_next_page
      - has_prev_page
      properties:
        total_count:
          type: integer
          description: Matching ITEMS across every page — NOT the response's `counts_total`,
            which is every match of all three record types.
          example: 16
        current_page:
          type: integer
          example: 1
        total_pages:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          description: "`current_page < total_pages`. Page until this is false; do
            not infer the end from a short page."
          example: false
        has_prev_page:
          type: boolean
          example: false
    LibrarySearchCategory:
      type: object
      description: 'One matching category. Categories have no card elsewhere in this
        namespace (the detail endpoint nests them inside a library, with their items),
        so this is the shape a RESULT needs: enough to draw the row and to open the
        library scoped to that category.'
      required:
      - id
      - name
      - icon
      - search_score
      - library
      - link
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: People
        icon:
          type: string
          description: The category's own Font Awesome class, falling back to `fas
            fa-folder` — the same fallback the web block and the detail endpoint apply.
          example: fas fa-folder
        search_score:
          type: integer
          example: 6
        library:
          type: object
          description: The library this category belongs to, for the row's path line.
          required:
          - id
          - name
          - color
          properties:
            id:
              type: integer
              example: 8
            name:
              type: string
              example: Company Policies
            color:
              type: string
              description: The library's mark colour, with the web card's fallback
                applied.
              example: "#2f64b1"
        link:
          type: string
          format: uri
          description: ABSOLUTE URL to the library page, anchored at this category's
            block — the same destination the web result row links to. Absolute because
            a native client cannot resolve a host-relative path.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/8#category-42
    LiveBoard:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
          - active
          - archived
        source_type:
          type: string
          description: Where the board reads from, e.g. `power_table_view` or `recognitions`.
        sample_data:
          type: boolean
          description: True for a board created by "Load sample data".
        visualizations_count:
          type: integer
          description: The number of readings IN `visualizations` — i.e. the readings
            this caller may see, not the board's total.
        visualizations:
          type: array
          items:
            "$ref": "#/components/schemas/LiveBoardReading"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    LiveBoardReading:
      type: object
      description: One reading (visualization) on a board.
      properties:
        id:
          type: integer
        name:
          type: string
        visualization:
          type: string
          description: One of ranked_list, stat, line, table, chart, movers, gauge,
            status, share, calendar, streak, pace, scorecard.
        position:
          type: integer
        source_name:
          type: string
          nullable: true
          description: The source table's name, or null for a native-source reading.
        data:
          "$ref": "#/components/schemas/LiveBoardReadingData"
    LiveBoardReadingData:
      type: object
      description: The reading's current values. Present on the board detail endpoint
        only. The members below are common to every visualization; the rest of the
        object varies by type (`entries` for ranked lists and status grids, `value`/`goal`
        for stats, gauges and pace, `points` for lines, and so on). Masking is already
        applied.
      properties:
        viz:
          type: string
        period_label:
          type: string
          nullable: true
        source_name:
          type: string
          nullable: true
        source_updated_at:
          type: string
          format: date-time
          nullable: true
        source_url:
          type: string
          nullable: true
          description: Drill-down into the source table. Present only when this caller
            could open it.
        data_version:
          type: integer
          nullable: true
        error:
          type: string
          description: Present INSTEAD of the values when the reading's source is
            unavailable or its configuration no longer matches it.
    LiveBoardPlaylist:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
          - active
          - archived
        interval_seconds:
          type: integer
          description: Seconds each board is held on screen (15–600).
        board_ids:
          type: array
          items:
            type: integer
        shared:
          type: boolean
          description: Whether a public rotation link currently exists.
        rotation_url:
          type: string
          nullable: true
          description: App admins only, and null when the rotation is not shared.
            An unauthenticated URL — treat it as a credential.
        embed_allowed_origins:
          type: array
          description: App admins only. Sites permitted to frame the rotation. Empty
            means the rotation is framed only where every one of its boards allows
            it; if none of them restrict framing, any site may.
          items:
            type: string
        boards:
          type: array
          description: Detail endpoint only — the rotation's readings, in play order.
          items:
            "$ref": "#/components/schemas/LiveBoardReading"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    NotificationsHomeNeedsYou:
      type: object
      description: Open asks waiting on this user, grouped by subject.
      required:
      - title
      - ordering
      - total_count
      - unread_count
      - entries
      properties:
        title:
          type: string
          description: Section heading, ready to render.
          example: Needs you
        ordering:
          type: string
          enum:
          - longest_waiting_first
          description: 'The order `entries` is already in — urgent first, then longest
            waiting first. Render as received rather than re-sorting.

            '
        total_count:
          type: integer
          description: Open asks across the whole section.
          example: 12
        unread_count:
          type: integer
          description: Unread open asks across the whole section.
          example: 5
        entries:
          type: array
          items:
            "$ref": "#/components/schemas/NotificationsHomeNeedsYouEntry"
    NotificationsHomeNeedsYouEntry:
      type: object
      description: 'One row of the Needs-you section: a collapsed subject group (`group:
        true`) or a single ask that sat below the collapse threshold (`group: false`).
        Both shapes carry the same keys.

        '
      required:
      - group
      - key
      - label
      - icon
      - count
      - unread_count
      - high_priority
      - oldest_at
      - oldest_waiting_label
      properties:
        group:
          type: boolean
          description: 'True for a collapsed subject group, false for a single ask.
            Groups form only at 3+ items in the subject.

            '
          example: true
        key:
          type: string
          description: Subject key — pass to `?subject=` to filter to it.
          example: leave_notifications
        label:
          type: string
          description: Subject label, as the user's notification settings name it.
          example: Time off
        title:
          type: string
          nullable: true
          description: 'The heading to render — the subject label for a group, the
            notification''s own title for a single ask.

            '
          example: Time off
        icon:
          type: string
          description: Font Awesome icon name, without the `fa-` prefix.
          example: umbrella-beach
        count:
          type: integer
          description: Open asks in this entry — renders as the count pill.
          example: 4
        unread_count:
          type: integer
          description: Unread asks in this entry — renders as the "N new" pill.
          example: 2
        high_priority:
          type: boolean
          description: 'True when any ask in the entry is high priority — render the
            **Urgent** pill.

            '
          example: true
        oldest_at:
          type: string
          format: date-time
          nullable: true
          description: When the oldest ask in this entry arrived (UTC ISO 8601).
          example: '2026-08-19T03:39:20Z'
        oldest_waiting_label:
          type: string
          nullable: true
          description: '`oldest_at` as the web phrases it, ready to render after "oldest
            waiting".

            '
          example: 7 days
        notifications:
          type: array
          description: 'The entry''s rows, so expanding costs no second request. A
            single-ask entry holds exactly one.

            '
          items:
            "$ref": "#/components/schemas/Notification"
    NotificationsHomeActivity:
      type: object
      description: 'Everything that merely happened, counted per subject and never
        listed.

        '
      required:
      - title
      - ordering
      - total_count
      - unread_count
      - sections
      properties:
        title:
          type: string
          example: Activity
        ordering:
          type: string
          enum:
          - largest_first
          description: The order `sections` is already in — biggest pile first.
        total_count:
          type: integer
          example: 440
        unread_count:
          type: integer
          description: Drives the "· N new" suffix on the section heading.
          example: 278
        sections:
          type: array
          items:
            "$ref": "#/components/schemas/NotificationsHomeActivitySection"
    NotificationsHomeActivitySection:
      type: object
      description: |
        One Activity subsection. Carries counts only — fetch its rows with `GET /notifications?filter=<key>` when the user expands it — this key is
            what the Activity group's "See all →" opens.
      required:
      - key
      - label
      - icon
      - count
      - unread_count
      - newest_at
      properties:
        key:
          type: string
          description: Subject key — pass to `?subject=` to list its rows.
          example: company_store
        label:
          type: string
          example: Company Store
        icon:
          type: string
          description: Font Awesome icon name, without the `fa-` prefix.
          example: store
        count:
          type: integer
          example: 139
        unread_count:
          type: integer
          description: Renders as the "N new" pill.
          example: 139
        newest_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the latest notification in this subsection was received
            (UTC ISO 8601).

            '
          example: '2026-08-26T05:52:17Z'
        latest_label:
          type: string
          nullable: true
          description: '`newest_at` in the inbox''s short form — "now", "45m", "3h",
            "2d", or "Mar 4" beyond a month. Ready to render after "latest".

            '
          example: 3h
    Notification:
      type: object
      properties:
        id:
          type: integer
          description: Unique notification ID
          example: 42
        title:
          type: string
          description: Notification title
          example: New shift assigned
        content:
          type: string
          description: Notification body content
          example: You have been assigned to the morning shift on Feb 20.
        notification_type:
          type: string
          description: Type/category of the notification
          example: system
        read:
          type: boolean
          description: Whether the notification has been read
          example: false
        read_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp when the notification was read (ISO 8601)
          example: '2026-02-19T10:30:00Z'
        archived:
          type: boolean
          description: Whether the notification is archived
          example: false
        archived_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the notification was archived (ISO 8601), null when it
            is not archived. This — not created_at — is the sort key for the archived
            list, so a client holding a cached list can re-sort locally after an archive/unarchive
            instead of waiting for the next fetch.

            '
          example: '2026-02-19T11:45:00Z'
        action_url:
          type: string
          nullable: true
          description: Optional URL for the notification action
          example: "/shifts/123"
        source_type:
          type: string
          nullable: true
          description: Polymorphic source type
          example: Shift
        source_id:
          type: integer
          nullable: true
          description: Polymorphic source ID
          example: 123
        metadata:
          type: object
          nullable: true
          description: Additional metadata as key-value pairs
          example: {}
        created_at:
          type: string
          format: date-time
          description: Creation timestamp (ISO 8601)
          example: '2026-02-19T08:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: Last update timestamp (ISO 8601)
          example: '2026-02-19T10:30:00Z'
    RecognitionConfig:
      type: object
      description: The tenant + viewer configuration behind `GET /recognitions/config`.
        Every caller receives every key; the role story lives in the VALUES.
      required:
      - module_enabled
      - module_label
      - viewer_role
      - permissions
      - features
      - economy
      - limits
      - default_visibility
      - visibility_options
      - values
      - tags
      - cards
      - programs
      properties:
        module_enabled:
          type: boolean
          description: Is Recognize on for this org AND visible to this viewer. Always
            true in a 200 — the endpoint 403s otherwise — and reported so a client
            can cache one settings shape.
          example: true
        module_label:
          type: string
          description: The app's display name. Terminology varies per org, and the
            name lives on the app record, so a console rename reaches the native shell
            with no client release.
          example: Recognitions
        viewer_role:
          type: string
          enum:
          - employee
          - manager
          - admin
          description: "`manager` = has direct reports. `admin` = a business admin/owner
            or a Recognitions app-admin, and outranks `manager` when the viewer is
            both. Drives the role-adaptive footer, the More menu and Team gating."
          example: manager
        permissions:
          type: object
          description: What THIS viewer may do. Each boolean is the same guard the
            corresponding write path enforces, so a control rendered from one of these
            is a control whose POST will be accepted.
          required:
          - can_give
          - can_nominate
          - can_boost
          - can_react
          - can_approve
          - can_quick_award
          - can_moderate
          properties:
            can_give:
              type: boolean
              description: May give peer recognition at all. False when the tenant
                switched peer recognition off and the viewer is neither a manager
                nor an admin.
              example: true
            can_nominate:
              type: boolean
              description: May file a nomination in AT LEAST ONE listed program right
                now. False means the picker would be empty — hide the entry point.
                Also false whenever award requests (Model A) are off.
              example: true
            can_boost:
              type: boolean
              description: Whether the Boost affordance exists for this viewer — the
                tenant has peer points on AND this viewer may give. Per-post eligibility
                (not your own give, not already boosted, enough allowance left) rides
                on each post's own `boost` block.
              example: false
            can_react:
              type: boolean
              description: The tenant's reactions switch. Defaults on.
              example: true
            can_approve:
              type: boolean
              description: May decide nominations and held posts — the reviewer rule
                the approvals queue and the decision endpoints enforce.
              example: true
            can_quick_award:
              type: boolean
              description: May give an instant manager award with no approval step.
                Requires BOTH the tenant's Quick Award switch and reviewer standing.
                The service still refuses an award to someone who is not a direct
                report — that check is per-recipient.
              example: true
            can_moderate:
              type: boolean
              description: May remove or revoke SOMEONE ELSE'S recognition. An author
                may always remove their own give; that is per-record and is reported
                on the item itself.
              example: false
        features:
          type: object
          description: The TENANT switches that decide which surfaces exist, separate
            from `permissions`, which decide what this viewer may do with them.
          properties:
            award_requests_enabled:
              type: boolean
              description: Model A — ad-hoc award requests (nominate → approve → award).
                Defaults on.
              example: true
            award_cycles_enabled:
              type: boolean
              description: Model B — time-boxed award cycles. Defaults off.
              example: false
            quick_award_enabled:
              type: boolean
              example: true
            comments_enabled:
              type: boolean
              example: true
            reactions_enabled:
              type: boolean
              example: true
            tags_enabled:
              type: boolean
              description: When false, `tags` is empty and the composer should hide
                the tag picker rather than rendering an empty one.
              example: true
            anonymous_allowed:
              type: boolean
              description: Whether `anonymous` appears in `visibility_options`. Defaults
                OFF — recognition is attributed by default.
              example: false
        economy:
          type: object
          description: The points economy, as this tenant configured it. All figures
            are zero while `points_enabled` is false, so a client never advertises
            a pot that cannot be spent.
          properties:
            points_enabled:
              type: boolean
              description: Peer redeemable points. Requires BOTH the admin toggle
                and a working Company Store integration — the give path refuses on
                either.
              example: true
            wallet_allowance:
              type: integer
              description: THIS viewer's monthly "points to give" allowance.
              example: 1000
            wallet_remaining:
              type: integer
              description: What is left of it this month. Resets monthly; does not
                carry over.
              example: 750
            wallet_tier:
              type: string
              enum:
              - ic
              - manager
              - leadership
              description: Which allowance band this viewer falls in.
              example: ic
            manager_allowance:
              type: integer
              description: The MANAGER band's configured monthly allowance, regardless
                of this viewer's own band — what a manager-facing screen budgets against.
                Reported to every viewer so one shell renders for all.
              example: 2000
            boost_tiers:
              type: array
              description: The pile-on amounts a Boost offers. The server rejects
                anything else. A post's own `boost` block narrows these to what the
                viewer's remaining allowance covers.
              items:
                type: integer
              example:
              - 5
              - 10
              - 25
            points_per_dollar:
              type: integer
              description: The store's conversion rate. The economy is STORED in dollars
                and PRESENTED in points; use this rather than assuming 100.
              example: 100
            point_tiers:
              type: array
              description: The give form's points selector, in order, always leading
                with the 0-point shout-out so "no points" is an offered choice rather
                than an empty field.
              items:
                type: object
                properties:
                  points:
                    type: integer
                    example: 250
                  label:
                    type: string
                    example: 250 pts
        limits:
          type: object
          description: What the server will actually accept — read from the constants
            the validations and the group fan-out are written from.
          properties:
            max_recipients:
              type: integer
              description: The most people ONE give may name.
              example: 50
            message_min:
              type: integer
              example: 10
            message_max:
              type: integer
              example: 1000
            nomination_title_min:
              type: integer
              example: 2
            nomination_title_max:
              type: integer
              example: 100
            nomination_description_min:
              type: integer
              example: 10
            nomination_description_max:
              type: integer
              example: 1000
            nomination_justification_max:
              type: integer
              example: 2000
            nomination_supporting_files_max_count:
              type: integer
              description: Attachments one nomination may carry.
              example: 5
            nomination_supporting_file_max_bytes:
              type: integer
              description: Per-file size cap, in bytes.
              example: 10485760
            nomination_supporting_file_extensions:
              type: array
              description: The extensions the uploader accepts. Use these for the
                file picker's filter rather than hardcoding a list.
              items:
                type: string
              example:
              - ".pdf"
              - ".png"
              - ".jpg"
              - ".docx"
              - ".xlsx"
              - ".csv"
              - ".txt"
            nomination_supporting_file_types_label:
              type: string
              description: The same set as human copy, for the uploader's hint line.
              example: PDF, Word, Excel, CSV, TXT, PNG, JPG, GIF or WEBP
        default_visibility:
          type: string
          enum:
          - public
          - private
          description: The composer's untouched default. A tenant configured non-public
            defaults its gives to `private` — and only then is `private` offered.
          example: public
        visibility_options:
          type: array
          description: The composer's visibility AND anonymity toggles, filtered to
            what this org allows, in composer order.
          items:
            type: object
            properties:
              value:
                type: string
                enum:
                - public
                - department
                - private
                - anonymous
                description: The wire value. `department` is the prototype's "my_department".
                  The legacy `team` synonym is never offered.
                example: department
              param:
                type: string
                enum:
                - visibility
                - is_anonymous
                description: Which request parameter this option is submitted under
                  — `anonymous` is the separate `is_anonymous` boolean, not a `visibility`
                  value.
                example: visibility
              label:
                type: string
                example: My Department
              description:
                type: string
                example: Visible to people in your department
        values:
          type: array
          description: The tenant's active company values, in picker order. Giving
            points REQUIRES one, so a brand-new tenant is seeded with a starter set
            on first read rather than being shown an empty picker.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 12
              name:
                type: string
                example: Customer First
              slug:
                type: string
                example: customer-first
              description:
                type: string
                nullable: true
                example: Puts customers at the center of every decision
              icon:
                type: string
                nullable: true
                description: Admin-set; fall back to your own neutral chip when null.
                example: fas fa-handshake
              color:
                type: string
                nullable: true
                example: "#0d6efd"
        tags:
          type: array
          description: The tag chips the composer offers. An admin-curated list is
            used verbatim; otherwise the tenant's own most-used tags of the last 30
            days topped up with platform defaults. Empty when `tags_enabled` is false.
          items:
            type: string
          example:
          - teamwork
          - excellence
          - innovation
        cards:
          type: object
          description: The award-card gallery, split by where each design comes from.
          properties:
            tenant:
              type: array
              description: Tenant-authored cards. Only designs that actually have
                art are listed.
              items:
                "$ref": "#/components/schemas/RecognitionAwardCard"
            gallery:
              type: array
              description: Central-gallery cards (the catalog also shown at mangoapps.com/templates/recognition).
                Empty — never an error — when the gallery is unreachable.
              items:
                "$ref": "#/components/schemas/RecognitionAwardCard"
            gallery_truncated:
              type: boolean
              description: 'True when the gallery page came back FULL: the catalog
                may hold designs this payload never listed. Say so in a picker''s
                "no match" state rather than letting a search miss read as "that card
                doesn''t exist".'
              example: false
        programs:
          type: object
          description: The nominate picker's vocabulary.
          properties:
            items:
              type: array
              items:
                "$ref": "#/components/schemas/RecognitionConfigProgram"
            total_count:
              type: integer
              description: The tenant's FULL nominatable-program count, so a client
                can say "3 of 12" and link to the paginated Programs screen.
              example: 3
            truncated:
              type: boolean
              description: Whether more programs exist than this payload listed.
              example: false
    RecognitionAwardCard:
      type: object
      description: One award-card design for the give composer's picker. Submit `award_template_id`
        VERBATIM — it is an integer for a tenant card and the string `"central:<slug>"`
        for a gallery card.
      properties:
        source:
          type: string
          enum:
          - tenant
          - gallery
          example: tenant
        id:
          type: integer
          nullable: true
          description: The local record id. Null for a gallery card, which has none.
          example: 8
        award_template_id:
          oneOf:
          - type: integer
          - type: string
          description: The value POST /recognition/give takes for this card.
          example: 8
        name:
          type: string
          example: Star Performer
        slug:
          type: string
          nullable: true
          example: star-performer
        default_message:
          type: string
          nullable: true
          description: The message the composer pre-fills when this card is picked.
            Tenant cards only.
          example: You went above and beyond.
        default_points:
          type: integer
          nullable: true
          example: 250
        category:
          type: string
          nullable: true
          description: Tenant cards only — the recognition category this design belongs
            to.
          example: Above & Beyond
        category_id:
          type: integer
          nullable: true
          example: 4
        description:
          type: string
          nullable: true
          description: Gallery cards only.
          example: A simple thanks.
        art_url:
          type: string
          nullable: true
          description: Absolute URL to the card art.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/star.png
    RecognitionConfigProgram:
      type: object
      description: One nominatable program, trimmed to what a NOMINATE PICKER needs.
        The full card — points-remaining bar, per-viewer counts, window dates — is
        served by `GET /recognitions/programs`.
      properties:
        id:
          type: integer
          example: 3
        name:
          type: string
          example: Above & Beyond
        slug:
          type: string
          nullable: true
          example: above-and-beyond
        description:
          type: string
          nullable: true
          example: For work that goes well past the brief.
        program_type:
          type: string
          example: peer_to_peer
        program_type_label:
          type: string
          example: Peer to Peer
        can_nominate:
          type: boolean
          description: Whether this viewer may file a nomination RIGHT NOW — the same
            answer the submit path gives. Hide the entry rather than disabling it.
          example: true
        nomination_block_reason:
          type: string
          nullable: true
          description: Why not, in the viewer's own words. Null when they can. Render
            it — a disabled row with no reason and no next step is a dead end.
          example: Quarterly Star Award is a Manager Recognition program — only managers
            can nominate in it. Recognize a teammate through a peer-to-peer program
            instead.
        categories:
          type: array
          description: The program's active categories, in sort order.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 9
              name:
                type: string
                example: Customer Impact
              slug:
                type: string
                example: customer-impact
              icon:
                type: string
                example: star
              color:
                type: string
                example: "#0d6efd"
    RecognitionGiveRequest:
      type: object
      description: The give composer's fields, one per control on the screen. Every
        option catalog behind them is served by `GET /recognitions/config`, so a client
        renders the picker from the same source the server validates against.
      properties:
        recipient_ids:
          type: array
          description: The people to recognize, in the order the client picked them,
            up to `limits.max_recipients` (50). Only people `GET /recognitions/employee_suggestions`
            offers are acceptable — active members of this business, service / AI-agent
            principals excluded. The caller's own id is dropped rather than refused.
          items:
            type: integer
          example:
          - 812
          - 907
        recipient_id:
          type: integer
          nullable: true
          description: Single-recipient form, for a one-person give and for the "Recognize
            <person>" deep link. Ignored when `recipient_ids` is present.
          example: 812
        content:
          type: string
          description: The recognition message — 10..1000 characters (`limits.message_min`
            / `message_max`). May be blank ONLY when `award_template_id` names a card
            that carries a default message, which then fills it.
          minLength: 10
          maxLength: 1000
          example: You carried the migration all weekend — thank you.
        award_template_id:
          type: string
          nullable: true
          description: 'The "Pick a card" selection, taken verbatim from `config.cards[].award_template_id`:
            a tenant card''s integer id (as a string or a number), or `"central:<slug>"`
            for a gallery card. An unknown id, a stale slug or a gallery outage is
            not an error — the give simply posts without a card.'
          example: central:thank-you-star
        points:
          type: integer
          minimum: 0
          default: 0
          description: Reward points from the giver's own monthly allowance — one
            of `economy.point_tiers`. `0` is a plain shout-out. Requires `company_value_id`
            when positive. Silently 0 when the tenant has peer points switched off.
          example: 250
        company_value_id:
          type: integer
          nullable: true
          description: The core value this recognition is tied to (one of `config.values`).
            REQUIRED once `points` is positive.
          example: 4
        recognition_tags:
          type: array
          description: Informal tags from `config.tags`. The first 5 are kept. A comma-separated
            string is accepted too.
          items:
            type: string
          example:
          - teamwork
          - above-and-beyond
        visibility:
          type: string
          enum:
          - public
          - department
          - private
          nullable: true
          description: One of `config.visibility_options`. **Omit it** to take the
            tenant's own default — do not send `public` as a fallback, which is how
            a private-by-default tenant's gives end up published publicly.
          example: department
        is_anonymous:
          type: boolean
          default: false
          description: Post without the giver's name. Forced false unless `features.anonymous_allowed`
            is true — anonymity is opt-in per tenant.
          example: false
        photo_url:
          type: string
          format: uri
          nullable: true
          description: A photo for this recognition, named by URL — the JSON equivalent
            of the web composer's file upload. Must be a **public HTTPS** URL serving
            a JPG, PNG, WebP or HEIC image under 10 MB; the format is decided by sniffing
            the bytes, not by the `Content-Type` header or the extension. The server
            fetches it inline and stores the bytes, so the response card's `photo_url`
            is OUR rendition and this link is never referenced again — a URL behind
            a login, a signed URL that expires, or one on a private network will not
            work. Anything unusable refuses the give with `invalid_photo_url` (422)
            and writes nothing. A group give stores one copy, shared by every row.
          example: https://cdn.example.com/uploads/install-day.jpg
    RecognitionGiveResult:
      type: object
      description: The created recognition, what the give actually did, and the caller's
        remaining allowance.
      required:
      - recognition
      - recognition_ids
      - status
      - message
      properties:
        recognition:
          "$ref": "#/components/schemas/RecognitionGiveCard"
        recognition_ids:
          type: array
          description: Every row the submission wrote — one per recipient. The card
            above collapses a group give into one row (as the feed does), so this
            is how a client addresses an individual row later, to open or delete it.
          items:
            type: integer
          example:
          - 4473
          - 4474
        status:
          type: string
          enum:
          - active
          - posting
          - pending_review
          - pending_approval
          description: What the give DID. `active` is live; `posting` is held for
            the automated content screen (the normal AI-moderation path) and publishes
            moments later; `pending_review` is a keyword/policy hold; `pending_approval`
            is routed to the recipient's manager. **Never assume a 201 means live.**
          example: active
        message:
          type: string
          description: The web flash for this `status`, word for word — safe to show
            as-is.
          example: Recognition for Priya Nair has been shared!
        giving_remaining:
          type: integer
          description: The caller's remaining monthly points-to-give allowance AFTER
            this give, so a wallet header updates without a second request. 0 when
            peer points are off.
          example: 750
        unread_notification_count:
          type: integer
          example: 3
    RecognitionGiveCard:
      type: object
      description: The created recognition as the FEED renders it — the same card
        shape `GET /recognitions/feed` returns, so a client can insert it optimistically
        and have the next refresh agree. A brand-new give has no reactions, comments
        or boosts, and its own author can never boost it.
      required:
      - type
      - kind
      - id
      - status
      - outcome_status
      properties:
        type:
          type: string
          enum:
          - recognition_post
          example: recognition_post
        kind:
          type: string
          enum:
          - recognition
          example: recognition
        id:
          type: integer
          example: 4471
        title:
          type: string
          nullable: true
        message:
          type: string
          nullable: true
          description: The recognition body — the giver's message, or the card's default
            copy when they left it blank.
        points:
          type: integer
          example: 250
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        group_recipients:
          type: array
          nullable: true
          description: Every recipient of the submission, for a group give. Always
            present and **null** for a single-recipient give — the field never disappears,
            so a client reads it the same way here, on the feed card and on the detail
            screen.
          items:
            "$ref": "#/components/schemas/RecognitionPersonCard"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          nullable: true
          description: The giver. **Null for an anonymous give**, even for the caller
            who just made it.
        anonymous:
          type: boolean
          example: false
        program:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        company_value:
          type: string
          nullable: true
          example: Customer First
        occurred_at:
          type: string
          format: date-time
          description: When it was published, falling back to when it was created
            — a give held for the content screen has no publish time yet but still
            sorts.
        visibility:
          type: string
          example: public
        status:
          type: string
          description: The persisted column. A give awaiting the content screen reads
            `pending_review` here — branch on `outcome_status` (or the top-level `status`)
            instead, which tells `posting` apart from a real moderator hold.
          example: active
        outcome_status:
          type: string
          enum:
          - active
          - posting
          - pending_review
          - pending_approval
          description: The same value as the response's top-level `status`, repeated
            on the card.
          example: active
        tags:
          type: array
          items:
            type: string
        photo_url:
          type: string
          nullable: true
          description: The photo the give carried, as the stored WebP rendition —
            the same value `GET /recognitions/feed` and `GET /recognitions/posts/{id}`
            report, so the card can be inserted optimistically. Always present, **null**
            when the give sent no `photo_url`. On a group give every row shares this
            one image.
        award_art_url:
          type: string
          nullable: true
          description: Art of the card that was attached. For a gallery card this
            is a freshly materialized tenant asset, so the client cannot know it in
            advance.
        boost:
          "$ref": "#/components/schemas/RecognitionBoost"
    RecognitionGiveError:
      type: object
      description: A refused give. `error.code` is the rule that refused it.
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
              enum:
              - no_recipients
              - invalid_recipients
              - too_many_recipients
              - content_missing
              - invalid_photo_url
              - governance_blocked
              - content_rejected
              - invalid
              - giving_not_allowed
              - access_denied
            message:
              type: string
              description: Ready to show. For `governance_blocked` this is the guard's
                own sentence, which names the limit and when it lifts.
            details:
              type: object
              nullable: true
              description: 'Present on the two refusals a client can act on structurally:
                `max_recipients` for `too_many_recipients`, `recipient_ids` for `invalid_recipients`.'
    RecognitionDeletion:
      type: object
      description: The result of the ⋯ ▸ Delete action, returned by both `DELETE /recognitions/posts/{id}`
        (soft delete) and `DELETE /recognitions/awards/{id}` (admin revoke). `type`
        says which table the recognition came from and `status` the past tense it
        landed in, so a client can render the confirmation without re-deriving either.
      required:
      - id
      - type
      - deleted
      - status
      - already_deleted
      properties:
        id:
          type: integer
          example: 4471
          description: The removed recognition's id.
        type:
          type: string
          enum:
          - recognition_post
          - award
          description: "`recognition_post` for a peer give, `award` for an award or
            automated certificate."
        deleted:
          type: boolean
          example: true
          description: Always true on a 200 — the recognition is no longer live.
        status:
          type: string
          enum:
          - deleted
          - revoked
          description: The row's new status — `deleted` for a soft-deleted post, `revoked`
            for an award.
        already_deleted:
          type: boolean
          example: false
          description: True when the recognition was ALREADY removed and this request
            was a no-op (posts only — a re-issued award revoke answers 422 `not_revocable`).
            Points are never reversed twice.
        message:
          type: string
          nullable: true
          example: Recognition removed.
          description: A ready-to-show confirmation, worded as the web words it.
        unread_notification_count:
          type: integer
          example: 3
    RecognitionEdit:
      type: object
      description: The result of the ⋯ ▸ Edit action, returned by both `PATCH /recognitions/posts/{id}`
        and `PATCH /recognitions/awards/{id}`. `recognition` is the SAME card the
        matching `GET` returns — one serializer, so a client replaces its row from
        this response instead of re-fetching, and there is no edit-only shape to keep
        in step.
      required:
      - recognition
      - unchanged
      properties:
        recognition:
          "$ref": "#/components/schemas/RecognitionDetail"
        unchanged:
          type: boolean
          example: false
          description: 'True when the body matched what was already stored: nothing
            was written, no moderation screen ran and no notification fired. A client
            can skip its "saved" animation on this.'
        message:
          type: string
          nullable: true
          example: Recognition updated.
          description: A ready-to-show confirmation, worded as the web words it.
        unread_notification_count:
          type: integer
          example: 3
    RecognitionComment:
      type: object
      description: 'ONE comment on a recognition post or award. Threading is ONE level
        deep: a top-level comment carries its direct replies inline in `replies`,
        and a serialized reply always has `parent_id` set and `replies: []`. Only
        ACTIVE replies are inlined, so a reply held for moderation or removed never
        reaches a client. Reactions are summarized; `can_edit`/`can_delete` are per-viewer
        and match what the write endpoints enforce.'
      required:
      - id
      - item_type
      - item_id
      - content
      - status
      - created_at
      - can_edit
      - can_delete
      properties:
        id:
          type: integer
          example: 991
        parent_id:
          type: integer
          nullable: true
          example: 990
          description: null on a top-level comment; the id of the comment this one
            replies to otherwise. Same key POST accepts.
        item_type:
          type: string
          enum:
          - recognition_post
          - award
          description: The canonical commentable type of the parent recognition.
        item_id:
          type: integer
          example: 4471
          description: The parent recognition's id.
        author:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          nullable: true
          description: The comment's author (id, name, title, image).
        content:
          type: string
          example: Fantastic work — well deserved!
        status:
          type: string
          enum:
          - active
          - hidden
          - deleted
          description: "`active` normally; `hidden` when held by moderation; `deleted`
            after removal."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        edited_at:
          type: string
          format: date-time
          nullable: true
          description: When the author last edited the CONTENT (nil if never edited;
            unaffected by moderation status flips).
        edited:
          type: boolean
          description: True iff `edited_at` is present.
        time_ago:
          type: string
          nullable: true
          example: about 2 hours ago
        replies_count:
          type: integer
          example: 2
          description: How many ACTIVE replies are inlined below. Always 0 on a reply.
        replies:
          type: array
          description: The comment's ACTIVE direct replies, oldest-first. Always empty
            on a reply — threads are one level deep — so this recursion terminates
            after one hop.
          items:
            "$ref": "#/components/schemas/RecognitionComment"
        reactions:
          type: object
          properties:
            total:
              type: integer
              example: 3
            top_emojis:
              type: array
              items:
                type: object
                properties:
                  emoji:
                    type: string
                    example: "\U0001F44D"
                  count:
                    type: integer
                    example: 2
            my_reactions:
              type: array
              items:
                type: string
                example: "❤️"
              description: The viewer's own emojis on this comment.
        can_edit:
          type: boolean
          description: Whether THIS caller may edit the comment (author only).
        can_delete:
          type: boolean
          description: Whether THIS caller may delete the comment (author or a Recognitions
            moderator).
    RecognitionCommentPageMeta:
      type: object
      required:
      - current_page
      - per_page
      - total_count
      - total_pages
      - has_next_page
      - has_prev_page
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total_count:
          type: integer
          example: 42
          description: Total ACTIVE TOP-LEVEL comments on the recognition — the rows
            this list pages over. Inlined replies are NOT counted here; the detail
            endpoint's `comments_count` counts every visible row instead.
        total_pages:
          type: integer
          example: 3
        has_next_page:
          type: boolean
          example: true
        has_prev_page:
          type: boolean
          example: false
    RecognitionDetail:
      type: object
      description: ONE recognition rendered for the detail screen — a peer post, an
        award, or an automated certificate. Shared verbatim between /recognitions/posts/{id}
        and /recognitions/awards/{id}; `type` and `kind` say which was resolved.
      required:
      - type
      - kind
      - id
      - engagement
      - permissions
      properties:
        type:
          type: string
          enum:
          - recognition_post
          - award
          description: The persisted model — `recognition_post` or `award`.
        kind:
          type: string
          enum:
          - recognition
          - award
          - certificate
          description: The user-facing kind shown in the feed. `recognition` = peer
            post, `award` = human-given award, `certificate` = automated award.
        id:
          type: integer
          example: 4471
        title:
          type: string
          nullable: true
          example: Great Work
        message:
          type: string
          nullable: true
          description: The recognition body (post content or award description/citation).
          example: You carried the migration all weekend — thank you.
        points:
          type: integer
          example: 40
          description: The recognition's worth in reward points.
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        group_recipients:
          type: array
          nullable: true
          description: Present only for a grouped give (one submission → many recipients).
          items:
            "$ref": "#/components/schemas/RecognitionPersonCard"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          nullable: true
          description: The giver. Null for an anonymous give; "System (Automated)"
            (id null) for an automated certificate.
        anonymous:
          type: boolean
          example: false
        program:
          type: string
          nullable: true
          example: Spot Award
        category:
          type: string
          nullable: true
          example: Teamwork
        company_value:
          type: string
          nullable: true
          example: Customer First
        occurred_at:
          type: string
          format: date-time
          nullable: true
        visibility:
          type: string
          nullable: true
          description: Post visibility (public/team/department/private) or public/private
            for an award.
          example: public
        status:
          type: string
          nullable: true
          example: active
        automated:
          type: boolean
          description: True for a system-generated certificate; always false for a
            post.
          example: false
        tags:
          type: array
          items:
            type: string
          description: Recognition tags (posts only; empty for awards).
        photo_url:
          type: string
          nullable: true
          description: Attached photo (posts only).
        award_art_url:
          type: string
          nullable: true
          description: Award-card art (posts with a card) or the certificate page
            (awards).
        engagement:
          "$ref": "#/components/schemas/RecognitionEngagement"
        boost:
          allOf:
          - "$ref": "#/components/schemas/RecognitionBoost"
          nullable: true
          description: Pile-on points block. Present for posts; null for awards (not
            boostable).
        permissions:
          "$ref": "#/components/schemas/RecognitionPermissions"
    RecognitionQuickAwardForm:
      type: object
      description: 'Everything the Quick Award form renders from. Persona-aware but
        not persona-branched: every reviewer receives the same keys, and what varies
        is the values — `recipient_scope`, the budget, and the recent-awards list.'
      required:
      - enabled
      - require_category
      - recipient_scope
      - amount
      - limits
      - programs
      - budget
      - recent_awards
      properties:
        enabled:
          type: boolean
          description: Always `true` in a 200 — the endpoint answers 403 `feature_disabled`
            otherwise. Reported so one client model covers both answers.
          example: true
        require_category:
          type: boolean
          description: Whether a category is mandatory (tenant setting `quick_award_require_category`).
            Render the required marker from this; the POST enforces it with 422 `category_required`.
          example: false
        recipient_scope:
          type: string
          enum:
          - all
          - direct_reports
          description: Who this caller may award, and therefore the `scope` to call
            `GET /recognitions/employee_suggestions` with. `all` for a Recognitions
            admin; `direct_reports` for a manager — the same rule the POST applies
            to `recipient_id`.
          example: direct_reports
        amount:
          "$ref": "#/components/schemas/RecognitionQuickAwardAmount"
        limits:
          "$ref": "#/components/schemas/RecognitionQuickAwardLimits"
        default_program_id:
          type: integer
          nullable: true
          description: What "Default Program" resolves to, and the program the POST
            funds the award from when `program_id` is omitted — so the `budget` below
            is the budget a default award actually draws down. Null only when the
            tenant has no active program at all, in which case the POST answers 422
            `no_program`.
          example: 7
        programs:
          type: array
          description: Active, in-window programs in name order — the web picker's
            own list. Empty when the tenant has none.
          items:
            "$ref": "#/components/schemas/RecognitionQuickAwardProgram"
        budget:
          "$ref": "#/components/schemas/RecognitionQuickAwardBudget"
        recent_awards:
          type: array
          description: 'This manager''s most recent awards in THIS tenant, newest
            first, capped at 5 — the web sidebar''s confirmation list. Finalized only:
            revoked and expired awards are excluded, matching every count of "recognition
            given". Deliberately smaller than a feed card; the full card is at `GET
            /recognitions/awards/{id}`.'
          items:
            "$ref": "#/components/schemas/RecognitionQuickAwardRecent"
    RecognitionQuickAwardAmount:
      type: object
      description: The amount input's bounds, **all in reward points**. The columns
        store dollars (1 pt = 1¢); the server converts both ways, so a client never
        handles dollars.
      required:
      - min_points
      - max_points
      - default_points
      - presets
      - points_per_dollar
      properties:
        min_points:
          type: integer
          description: The floor. A zero or negative amount is refused `invalid_amount`.
          example: 1
        max_points:
          type: integer
          description: The tenant's per-award cap (`quick_award_max_amount`). Never
            widen the input past this — the POST refuses it with `amount_over_limit`.
            A program's own `per_award_limit_points` and a category's range can be
            tighter still.
          example: 10000
        default_points:
          type: integer
          description: What to pre-fill (`quick_award_default_amount`), already clamped
            to `max_points` so it is never an amount the server would refuse.
          example: 2500
        presets:
          type: array
          description: The web "Quick select" ladder, already filtered to the cap
            — offered so a phone renders the same one-tap amounts instead of inventing
            its own. Falls back to `[max_points]` for a tenant whose cap is below
            the smallest rung.
          items:
            type: integer
          example:
          - 10
          - 25
          - 50
          - 100
          - 150
          - 200
          - 250
          - 500
        points_per_dollar:
          type: integer
          description: The tenant's conversion rate, for the rare screen that shows
            a currency figure. Everything else on this wire is points.
          example: 100
    RecognitionQuickAwardLimits:
      type: object
      description: What the two text fields will actually accept, read from the constants
        the `Award` validations are written from. Both fields are optional — the server
        substitutes a default when either is blank — but a value that IS sent has
        to clear these.
      required:
      - title_min
      - title_max
      - message_min
      - message_max
      properties:
        title_min:
          type: integer
          example: 2
        title_max:
          type: integer
          example: 100
        message_min:
          type: integer
          description: 'The one that surprises callers: a note that is sent must be
            at least this long, or the POST answers 422 `invalid`. Render a counter
            from it.'
          example: 10
        message_max:
          type: integer
          example: 1000
    RecognitionQuickAwardProgram:
      type: object
      description: One program in the picker, with its own categories.
      required:
      - id
      - name
      - categories
      properties:
        id:
          type: integer
          example: 7
        name:
          type: string
          example: Spot Awards
        slug:
          type: string
          example: spot-awards
        description:
          type: string
          nullable: true
          example: On-the-spot recognition for going above and beyond.
        program_type:
          type: string
          example: manager_to_employee
        program_type_label:
          type: string
          nullable: true
          description: The human label for `program_type`, as the web select shows
            it.
          example: Manager to Employee
        per_award_limit_points:
          type: integer
          nullable: true
          description: This program's own ceiling on a SINGLE award, in points. Null
            means no program cap — `amount.max_points` still applies. Bound the input
            by whichever is tighter, or the POST answers `amount_over_limit`.
          example: 5000
        categories:
          type: array
          description: Only this program's ACTIVE categories, in the picker's order.
            Empty when the program has none, in which case the category field has
            nothing to offer — a tenant that also sets `require_category` has a misconfiguration
            an admin has to fix.
          items:
            "$ref": "#/components/schemas/RecognitionQuickAwardCategory"
    RecognitionQuickAwardCategory:
      type: object
      description: A category chip and its value range, in points. `null` on a bound
        means unbounded on that side — which is a different answer from a bound of
        zero.
      required:
      - id
      - name
      properties:
        id:
          type: integer
          example: 31
        name:
          type: string
          example: Teamwork
        slug:
          type: string
          example: teamwork
        icon:
          type: string
          description: Icon name, falling back to the same default the web chip draws,
            so an unconfigured category never renders blank.
          example: handshake
        color:
          type: string
          description: Hex colour, with the same web fallback.
          example: "#112233"
        min_points:
          type: integer
          nullable: true
          description: Lowest amount this category accepts; null when unbounded.
          example: 100
        max_points:
          type: integer
          nullable: true
          description: Highest amount this category accepts; null when unbounded.
          example: 800
        default_points:
          type: integer
          nullable: true
          description: What to re-fill the amount with when this category is picked
            — what the web form does. Null when the category sets no default.
          example: 400
    RecognitionQuickAwardBudget:
      type: object
      description: 'The manager''s giving budget for the default program, every figure
        in points. `{ "enabled": false }` for most tenants — businesses without group
        budgets are unaffected by any of this — so branch on `enabled` before reading
        anything else.'
      required:
      - enabled
      properties:
        enabled:
          type: boolean
          description: Whether a group budget applies to this manager at all.
          example: true
        name:
          type: string
          nullable: true
          example: FY26 Engineering Recognition
        status:
          type: string
          nullable: true
          example: active
        exhausted:
          type: boolean
          description: The budget itself is spent out.
          example: false
        blocked:
          type: boolean
          description: The web submit button's own disabled condition — exhausted,
            OR this manager has nothing left. Disable Give on this rather than re-deriving
            it.
          example: false
        your_remaining_points:
          type: integer
          description: What THIS manager may still give from it.
          example: 42000
        your_spent_points:
          type: integer
          example: 8000
        max_per_manager_points:
          type: integer
          nullable: true
          description: The per-manager cap, or null when the budget sets none.
          example: 50000
        available_total_points:
          type: integer
          description: What the whole budget has left, across every manager on it.
          example: 310000
        annual_allocation_points:
          type: integer
          nullable: true
          example: 500000
    RecognitionQuickAwardRecent:
      type: object
      description: One row of the "Your Recent Awards" sidebar.
      properties:
        id:
          type: integer
          example: 9182
        title:
          type: string
          example: Shipped the migration
        points:
          type: integer
          description: The award's value in points.
          example: 500
        awarded_at:
          type: string
          format: date-time
          example: '2026-08-17T14:05:00Z'
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
    RecognitionQuickAwardRequest:
      type: object
      description: The Quick Award form's submission. `recipient_id` and `amount`
        are the only required fields — the minimum the native sheet collects; everything
        else has a documented server-side default.
      required:
      - recipient_id
      - amount
      properties:
        recipient_id:
          type: integer
          description: Who is being awarded. Must be one of this caller's direct reports
            unless they are a Recognitions admin, who may award anyone — see `recipient_scope`
            on the GET, and fill the picker from `GET /recognitions/employee_suggestions`.
          example: 412
        amount:
          type: integer
          description: The award, in reward POINTS. Within `amount.min_points` ..
            `amount.max_points`, and also within the chosen program's `per_award_limit_points`
            and the chosen category's range when either is set.
          example: 500
        program_id:
          type: integer
          nullable: true
          description: Which program funds it. Omit for the tenant's `default_program_id`.
            A program that is not active in this tenant is refused with `invalid_program`,
            never swapped for the default.
          example: 7
        category_id:
          type: integer
          nullable: true
          description: A category **of the chosen program**. An id belonging to a
            different program is silently ignored rather than refused, matching the
            web form where a category can only be picked after its program. Required
            when `require_category` is true.
          example: 31
        title:
          type: string
          nullable: true
          description: A short headline. Blank becomes `"Quick Award from {giver name}"`;
            a value that is sent must fit `limits.title_min`..`title_max`.
          example: Shipped the migration
        message:
          type: string
          nullable: true
          description: The personal note. Blank becomes `"Great work! Keep it up."`;
            a value that is sent must clear `limits.message_min` (10 characters) or
            the request is refused `invalid`.
          example: You carried the migration all weekend — thank you.
        is_public:
          type: boolean
          default: true
          description: Whether the award appears in the recognition feed and on any
            connected Slack/Teams recognition channel. **Defaults to true**, because
            the web checkbox is pre-checked — send `false` explicitly to keep it private.
          example: true
        anniversary_years:
          type: integer
          nullable: true
          description: |-
            Set this ONLY when the award is the recipient's work-anniversary award — the **Recognize** action on an anniversary row. Echo that row's own `years` from `GET /recognitions/anniversary_roster` (or `upcoming_anniversaries[].years` on `GET /recognitions/team`).

            With it, the award is RECORDED as that anniversary's recognition: the row comes back `recognized: true`, `status: "recognized"` and `can_recognize: false`, the roster's Recognized tile counts it, **Mark reward as sent** becomes available, and the automatic anniversary award is not given a second time. Without it the points still land but nothing is recorded, so the row keeps offering Recognize after every refresh.

            Omit it for an ordinary spot award — an inferred anniversary would cancel that employee's automatic award for the year. A value that is not one of this person's real anniversaries (the one they are heading into, or the one already passed this calendar year) is ignored; the award itself still succeeds.
          example: 10
    RecognitionQuickAwardCard:
      type: object
      description: 'The created award as the FEED renders it — the same card `GET
        /recognitions/feed` and `GET /recognitions/awards/{id}` return, so a client
        can insert it optimistically and get the same shape back on the next refresh.
        A Quick Award is never pending: a 201 means it is live.'
      properties:
        type:
          type: string
          enum:
          - award
          example: award
        kind:
          type: string
          enum:
          - award
          description: Always `award`, never `certificate` — a certificate is an automated
            lifecycle award, and a Quick Award is by definition given by a person.
          example: award
        id:
          type: integer
          example: 9182
        title:
          type: string
          example: Shipped the migration
        message:
          type: string
          nullable: true
          description: The award's description — the note the giver wrote.
          example: You carried the migration all weekend — thank you.
        points:
          type: integer
          example: 500
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        giver:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        anonymous:
          type: boolean
          description: Always false — a Quick Award is attributed to its giver.
          example: false
        automated:
          type: boolean
          example: false
        program:
          type: string
          nullable: true
          example: Spot Awards
        category:
          type: string
          nullable: true
          example: Teamwork
        visibility:
          type: string
          enum:
          - public
          - private
          description: Derived from `is_public`.
          example: public
        status:
          type: string
          enum:
          - active
          - revoked
          - expired
          example: active
        certificate_url:
          type: string
          nullable: true
          description: Absolute URL of the printable gold-framed certificate page
            for this award.
          example: https://acme.workforce.mangoapps.com/recognition/awards/9182/card
        tags:
          type: array
          description: Always empty — tags are a peer-recognition field, not an award
            one.
          items:
            type: string
          example: []
        boost:
          nullable: true
          description: Always null — awards cannot be boosted; only peer gives can.
        occurred_at:
          type: string
          format: date-time
          example: '2026-08-17T14:05:00Z'
    RecognitionQuickAwardRefusal:
      type: object
      description: A 403 from either Quick Award verb.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - feature_disabled
              - forbidden
              description: "`access_denied` — no access to the Recognitions app. `feature_disabled`
                — the tenant switched Quick Awards off; hide the affordance. `forbidden`
                — the caller is not a reviewer."
            message:
              type: string
    RecognitionQuickAwardError:
      type: object
      description: A refused give. Nothing was written. `code` names the rule so a
        client can highlight the offending field; `message` is the wording the web
        flash shows and is safe to display as-is.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - invalid_recipient
              - recipient_not_permitted
              - invalid_amount
              - amount_over_limit
              - category_required
              - governance_blocked
              - budget_exceeded
              - invalid_program
              - no_program
              - invalid
              - internal_error
              example: amount_over_limit
            message:
              type: string
              example: Award amount cannot exceed $100.00.
            details:
              type: object
              nullable: true
              description: Present on the amount refusals only — the bounds the amount
                was measured against, so a client can correct the field rather than
                asking the user to guess.
              properties:
                min_points:
                  type: integer
                  example: 1
                max_points:
                  type: integer
                  example: 10000
    RecognitionEmployeeSuggestion:
      type: object
      description: 'One suggestible recipient. The shared Recognitions person card
        plus the two fields a recipient picker needs, and which the web picker already
        shows: `email` (the disambiguator — "Chris Taylor" is two people in most tenants,
        and a picker that can''t tell them apart sends recognition to the wrong one)
        and `department` (the row''s secondary line).'
      properties:
        id:
          type: integer
          example: 412
        name:
          type: string
          example: Maya Chen
        title:
          type: string
          nullable: true
          description: Job title, or null when unset.
          example: Support Specialist
        image:
          type: string
          nullable: true
          description: Absolute avatar URL, or null when it can't be resolved.
        email:
          type: string
          nullable: true
          example: maya.chen@example.com
        department:
          type: string
          nullable: true
          description: Primary organizational department name, or null.
          example: Customer Support
    RecognitionEmployeeSuggestionsMeta:
      type: object
      description: The page envelope every paginated Recognitions endpoint answers
        in, plus the three fields a picker needs to render the right empty state and
        the right scope copy.
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total_count:
          type: integer
          description: Total suggestible colleagues matching this search and scope.
          example: 148
        total_pages:
          type: integer
          example: 6
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
        query:
          type: string
          nullable: true
          description: The search term as applied (trimmed; null when blank). Echoed
            so a client can tell "nobody matches Xyz" from "you have no colleagues
            to recognize" and render the right empty state for each.
          example: maya
        scope:
          type: string
          enum:
          - all
          - direct_reports
          description: The scope as applied — `all` when an unrecognized value was
            sent.
        direct_reports_only:
          type: boolean
          description: True when the roster was actually narrowed to this caller's
            direct reports, i.e. `scope=direct_reports` AND the caller is not an admin
            (an admin may Quick Award anyone, so nothing is narrowed for them). Read
            THIS, not the `scope` you sent, before showing "your team only" copy.
    RecognitionPersonCard:
      type: object
      description: The shared person card used across the Recognitions API.
      properties:
        id:
          type: integer
          nullable: true
          example: 42
        name:
          type: string
          example: Maya Chen
        title:
          type: string
          nullable: true
          example: Staff Engineer
        image:
          type: string
          nullable: true
          description: Absolute avatar URL.
    RecognitionEngagement:
      type: object
      description: Reaction and comment engagement, including the full list of reactions.
      required:
      - reactions_count
      - comments_count
      - reactions
      properties:
        reactions_count:
          type: integer
          example: 3
        reactions_enabled:
          type: boolean
          example: true
        my_reaction:
          type: string
          nullable: true
          description: The viewer's own reaction emoji, if any.
          example: "\U0001F389"
        reaction_summary:
          type: array
          description: Reactions grouped by emoji, most-used first.
          items:
            type: object
            properties:
              emoji:
                type: string
                example: "\U0001F44D"
              count:
                type: integer
                example: 2
        reactions:
          type: array
          description: Every reaction on this recognition, oldest first — emoji plus
            who left it.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 55012
              emoji:
                type: string
                example: "\U0001F44D"
              label:
                type: string
                example: Like
              user:
                "$ref": "#/components/schemas/RecognitionPersonCard"
              reacted_at:
                type: string
                format: date-time
        comments_count:
          type: integer
          example: 2
        comments_enabled:
          type: boolean
          example: true
        shares_count:
          type: integer
          example: 0
          description: Always 0 for awards.
    RecognitionBoost:
      type: object
      description: Peer pile-on points (posts only).
      properties:
        total_points:
          type: integer
          example: 25
          description: Points added by boosters so far.
        boosted_by_me:
          type: boolean
          example: false
        can_boost:
          type: boolean
          example: true
        amounts:
          type: array
          items:
            type: integer
          description: The boost amounts the viewer may give, capped to their remaining
            allowance.
          example:
          - 5
          - 10
          - 25
    RecognitionBoostReceipt:
      type: object
      description: The boost that was just applied, returned by `POST /recognitions/posts/{id}/boost`.
        The points have already left the booster's allowance and landed in the recipient's
        store balance — this is a receipt, not a pending request.
      required:
      - id
      - points
      - booster
      - recipient
      properties:
        id:
          type: integer
          example: 812
        points:
          type: integer
          enum:
          - 5
          - 10
          - 25
          example: 10
        boosted_at:
          type: string
          format: date-time
        booster:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          description: The caller — the person whose allowance paid for this boost.
        recipient:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          description: The recognition's recipient — the person whose balance was
            credited.
    RecognitionBoostTarget:
      type: object
      description: The boosted post's refreshed state, so a client can patch the card
        it already has rather than re-fetching the feed or the detail screen. The
        `boost` block is the SAME shape those two endpoints return.
      required:
      - id
      - type
      - boost
      properties:
        id:
          type: integer
          example: 4471
        type:
          type: string
          enum:
          - recognition_post
          description: Always `recognition_post` — awards are not boostable.
        boost:
          allOf:
          - "$ref": "#/components/schemas/RecognitionBoost"
          description: Recomputed after the write, so `boosted_by_me` is true, `can_boost`
            is false (one boost per person per post) and `amounts` is empty.
    RecognitionBoostError:
      type: object
      description: A refused boost. `details` carries the post's CURRENT boost block
        and the caller's remaining allowance, so a client whose card was stale can
        reconcile from the refusal instead of re-fetching the screen.
      required:
      - error
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
              enum:
              - invalid_boost
              - insufficient_allowance
              - boost_failed
              example: insufficient_allowance
            message:
              type: string
              description: Safe to show to the user verbatim — the same wording the
                web flashes.
              example: You only have 5 giving points left this month.
            details:
              type: object
              properties:
                boost:
                  "$ref": "#/components/schemas/RecognitionBoost"
                giving_remaining:
                  type: integer
                  description: The caller's remaining monthly giving allowance (unchanged
                    — a refusal spends nothing).
                  example: 5
    RecognitionPermissions:
      type: object
      description: Per-viewer capability flags — the affordances the server would
        honour.
      required:
      - can_edit
      - can_delete
      - can_boost
      properties:
        can_edit:
          type: boolean
          description: 'May PATCH this recognition''s message/company value: the author
            or a moderator on a post, a moderator on an award, and only while it is
            still active.'
        can_delete:
          type: boolean
          description: Author/admin delete for a post; admin revoke for an award.
        can_boost:
          type: boolean
          description: Peer pile-on eligibility; always false for awards.
        can_share:
          type: boolean
        can_comment:
          type: boolean
        can_react:
          type: boolean
    WrappedPersonCount:
      type: object
      description: A person named in a Recognition Wrapped list, plus how many recognitions
        were exchanged with them.
      required:
      - user
      - count
      properties:
        user:
          type: object
          description: The shared person card used across the Recognitions API.
          properties:
            id:
              type: integer
              example: 42
            name:
              type: string
              example: Maya Chen
            title:
              type: string
              nullable: true
              example: Engineering Manager
            image:
              type: string
              nullable: true
              description: Absolute avatar URL
              or null.:
              example: https://cdn.example.com/avatars/42.jpg
        count:
          type: integer
          description: Number of recognitions exchanged with this person this year.
          example: 4
    RecognitionBulkReviewResult:
      type: object
      description: 'The outcome of a bulk approve/reject. A 200 means the batch was
        accepted; it may still carry skipped or errored rows, so read the counts.
        The three lists partition the requested ids: `processed` succeeded, `skipped_ids`
        were not acted on (not reviewable / already decided / another tenant''s),
        and `errors` failed mid-write.'
      required:
      - action
      - requested_count
      - processed_count
      - skipped_count
      - error_count
      - processed
      - skipped_ids
      - errors
      properties:
        action:
          type: string
          enum:
          - approve
          - reject
          example: approve
        message:
          type: string
          description: A human-readable summary, mirroring the web flash.
          example: 3 nominations approved.
        requested_count:
          type: integer
          description: How many distinct ids the caller submitted (after de-duplication).
          example: 5
        processed_count:
          type: integer
          example: 3
        skipped_count:
          type: integer
          example: 2
        error_count:
          type: integer
          example: 0
        processed:
          type: array
          description: The nominations that were approved/rejected.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 8123
              nominee:
                "$ref": "#/components/schemas/RecognitionPerson"
              status:
                type: string
                enum:
                - pending
                - under_review
                - approved
                - rejected
                - cancelled
                example: approved
              finalized:
                type: boolean
                description: True once terminal (award minted / rejected); false while
                  a multi-level chain is still advancing.
                example: true
              approval_level:
                type: integer
                description: Which approval step is current, of how many.
                example: 1
              approval_levels_required:
                type: integer
                example: 1
        skipped_ids:
          type: array
          description: Requested ids that were NOT acted on — not reviewable by the
            caller, already decided, or another tenant's.
          items:
            type: integer
          example:
          - 8140
          - 8141
        errors:
          type: array
          description: Per-row failures. Each names the nominee and the reason the
            write raised.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 8130
              name:
                type: string
                example: Ada Lovelace
              message:
                type: string
                example: 'Validation failed: Approved value must be greater than or
                  equal to 0'
        unread_notification_count:
          type: integer
          description: The caller's unread notification count, for the app badge.
          example: 3
    RecognitionNominationInput:
      type: object
      description: The nominate composer's fields — the same set the web form (`recognition/nominate.html.erb`)
        submits. Build the pickers from `GET /recognitions/config`.
      required:
      - recognition_program_id
      - nominee_id
      - title
      - description
      properties:
        recognition_program_id:
          type: integer
          description: The award being nominated for. Must be a program that is nominatable
            right now AND open to this caller — `config.programs[]` reports both as
            `can_nominate`.
          example: 41
        nominee_id:
          type: integer
          description: 'The colleague being nominated. Must be a member of the caller''s
            business, and cannot be the caller. Picker: `GET /recognitions/employee_suggestions`.'
          example: 9021
        title:
          type: string
          description: A short award-worthy headline. Bounds are `config.limits.nomination_title_*`.
          minLength: 2
          maxLength: 100
          example: Renewal save of the quarter
        description:
          type: string
          description: What the nominee did that deserves recognition. Bounds are
            `config.limits.nomination_description_*`.
          minLength: 10
          maxLength: 1000
          example: Turned a churn risk into a multi-year renewal.
        recognition_category_id:
          type: integer
          nullable: true
          description: Optional. Must belong to the chosen program (`config.programs[].categories`).
            Its `min_points` / `max_points` bound `requested_value`.
          example: 77
        justification:
          type: string
          nullable: true
          description: Optional supporting detail — the measurable impact. Cap is
            `config.limits.nomination_justification_max`.
          maxLength: 2000
          example: Cut renewal turnaround from 5 days to 1 across 40 accounts.
        requested_value:
          type: integer
          nullable: true
          description: Optional suggested award value in reward POINTS (converted
            server-side to stored dollars at `config.economy.points_per_dollar`).
            Must be zero or greater, and within the chosen category's range and the
            program's per-award cap.
          example: 250
        is_public:
          type: boolean
          nullable: true
          description: Whether the nomination appears in the recognition feed. OMITTED
            falls back to the TENANT's configured default visibility, not to `true`.
          example: true
        is_anonymous:
          type: boolean
          nullable: true
          description: Hide the nominator's name. Silently forced to `false` while
            the tenant has anonymous recognition switched off (`config.permissions`
            / `visibility_options` report whether it is offered), exactly as the web
            form hides the checkbox.
          example: false
    RecognitionNominationDecision:
      type: object
      description: One nomination row — the shared card that both reviewer decisions
        (approve/reject) and the file endpoint (`POST /recognitions/nominations`)
        answer with, so a client keys off ONE shape across the whole nomination lifecycle.
        `reviewer_notes` carries the decision note (the approval note after approve,
        the rejection reason after reject) and is null on a freshly filed nomination;
        `approval_complete` is true once the request reaches a terminal state (award
        minted, or rejected) and false while a multi-level chain is still advancing
        — a nomination filed into an approval-off tenant is born complete.
      properties:
        type:
          type: string
          description: Always `nomination`, so a client can key off one row shape.
          example: nomination
        id:
          type: integer
          example: 8123
        title:
          type: string
          example: Renewal save of the quarter
        status:
          type: string
          enum:
          - pending
          - under_review
          - approved
          - rejected
          - cancelled
          example: approved
        display_status:
          type: string
          example: Approved
        status_color:
          type: string
          description: The hex the web badge is painted with, so both surfaces agree
            on the status colour.
          example: "#16a34a"
        approval_complete:
          type: boolean
          description: True once the request is terminal (award minted / rejected);
            false while advancing a multi-level chain.
          example: true
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          "$ref": "#/components/schemas/RecognitionPerson"
        reviewer:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: The deciding reviewer, or the NEXT approver on a multi-level
            advance.
        program:
          type: string
          nullable: true
          example: Spot Awards
        category:
          type: string
          nullable: true
          example: Customer Impact
        approval_level:
          type: integer
          description: Which approval step is current, of how many.
          example: 1
        approval_levels_required:
          type: integer
          example: 1
        points:
          type: integer
          description: What the nomination is worth right now, in reward POINTS —
            the approved figure once a reviewer has set one, the requested figure
            until then. The same key (and unit) the My Recognition rows carry, so
            one card component renders a nomination wherever it meets one. 0 when
            it was filed without a value.
          example: 250
        requested_value:
          type: string
          nullable: true
          description: The originally requested award value, in stored DOLLARS, as
            a decimal string. Use `requested_points` unless you specifically mean
            money.
          example: '2.5'
        approved_value:
          type: string
          nullable: true
          description: The approved award value, in stored dollars (points ÷ points-per-dollar),
            as a decimal string. Use `approved_points` unless you specifically mean
            money.
          example: '5.0'
        requested_points:
          type: integer
          nullable: true
          description: "`requested_value` in reward points. Null (not 0) when no value
            was asked for."
          example: 250
        approved_points:
          type: integer
          nullable: true
          description: "`approved_value` in reward points. Null until the nomination
            is approved."
          example: 500
        reviewer_notes:
          type: string
          nullable: true
          description: The decision note — the approval note, or the rejection reason.
          example: Clear, measurable impact on the renewal — well earned.
        submitted_at:
          type: string
          format: date-time
          nullable: true
        reviewed_at:
          type: string
          format: date-time
          nullable: true
    RecognitionAwardCycleBase:
      type: object
      description: The fields EVERY award-cycle card carries, whichever list it is
        in, so a client reads one shape and the four lists stay comparable.
      required:
      - id
      - name
      - status
      - status_label
      - selection_mode
      - opens_at
      - closes_at
      - award_points
      - nomination_count
      - nominee_count
      properties:
        id:
          type: integer
          example: 6
        name:
          type: string
          example: Mango Champions — August
        description:
          type: string
          nullable: true
          example: Our monthly peer-nominated award for people who make the work better
            for everyone.
        criteria:
          type: string
          nullable: true
          description: '"Looking for:" on the web card — what a good nomination looks
            like. Show it in the nominate form; it is the whole point of a criteria-led
            award.'
          example: Someone who went out of their way to help a teammate or a customer
            this month.
        status:
          type: string
          enum:
          - scheduled
          - open
          - closed
          - decided
          description: The EFFECTIVE status, which reconciles the stored column against
            the live window — not the stored `status`. This is what decides the list
            the card is in. Never re-derive it from the timestamps.
          example: open
        status_label:
          type: string
          description: The pill the web card paints next to the title.
          enum:
          - Opens soon
          - Open
          - Awaiting decision
          - Winners announced
          example: Open
        status_badge_class:
          type: string
          description: The Bootstrap class the web pill uses, so a native pill can
            carry the same treatment without maintaining its own mapping.
          example: bg-success-subtle text-success-emphasis
        selection_mode:
          type: string
          enum:
          - spotlight
          - committee
          description: "`spotlight` recognizes everyone meeting the distinct-nominator
            threshold; `committee` is decided by a named committee after the window
            closes."
          example: spotlight
        selection_mode_label:
          type: string
          enum:
          - Spotlight
          - Committee
          example: Spotlight
        opens_at:
          type: string
          format: date-time
        closes_at:
          type: string
          format: date-time
        recurrence:
          type: string
          enum:
          - none
          - weekly
          - monthly
          - quarterly
          example: monthly
        recurring:
          type: boolean
          description: Whether the next cycle is created automatically when this one
            is decided.
          example: true
        award_points:
          type: integer
          description: The prize, in the reward POINTS employees see everywhere else
            in this app. 0 means recognition only — no points attached.
          example: 500
        program:
          type: object
          nullable: true
          description: The recognition program this cycle runs under.
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Spot Recognition
        category:
          type: object
          nullable: true
          description: Null when the cycle isn't scoped to one category.
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Teamwork
        nomination_count:
          type: integer
          description: Total nominations pooled in this cycle.
          example: 7
        nominee_count:
          type: integer
          description: DISTINCT people nominated — the size of the pool a committee
            actually decides between. Never greater than `nomination_count`; 7 nominations
            from 3 nominees is a different job than 7 from 7.
          example: 3
        icon:
          type: string
          description: Cycles carry no icon of their own; every surface uses the trophy.
            Same glyph the dashboard's running-cycle banner sends.
          example: fas fa-trophy
    RecognitionAwardCycleOpenCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: An open cycle — the hero card, and the only list with a CTA.
        required:
        - days_left
        - closing_soon
        - can_nominate
        - nominated_by_me
        - my_nomination_count
        properties:
          days_left:
            type: integer
            description: Whole days until nominations close, floor 0 — the same arithmetic
              the dashboard banner uses, so the two screens never differ by a day
              for the same cycle.
            example: 8
          closing_soon:
            type: boolean
            description: True when `days_left` is within `meta.closing_soon_days`.
            example: false
          threshold_nominators:
            type: integer
            nullable: true
            description: How many DISTINCT nominators a nominee needs before a Spotlight
              cycle recognizes them. **Null for a committee cycle** — the committee
              decides, so a threshold is not what a nominator should aim at.
            example: 3
          can_nominate:
            type: boolean
            description: Whether this caller may nominate RIGHT NOW — the same answer
              the submit path gives. Hide the Nominate affordance when false.
            example: true
          nomination_block_reason:
            type: string
            nullable: true
            description: Why not, in the caller's words. Null when they can. Show
              this instead of a bare disabled button.
            example: Peer recognition is not enabled
          nominated_by_me:
            type: boolean
            description: Whether this caller has already nominated into this cycle.
              STATE, not a gate — a cycle pools nominations, so nominating again is
              allowed. Use it to say "you've nominated" rather than re-offering a
              fresh CTA with no memory.
            example: false
          my_nomination_count:
            type: integer
            description: How many nominations this caller filed here. Never exceeds
              `nomination_count`.
            example: 0
          nominate_url:
            type: string
            description: Absolute URL of the WEB nominate form (a native client can't
              resolve a relative path). Still sent because a WebView client uses it;
              a native client wants the two below.
            example: https://acme.workforce.mangoapps.com/recognition/award-cycles/6/nominate
          nominate_form_api_url:
            type: string
            description: 'Absolute URL of the NATIVE composer — `GET /recognitions/award_cycles/{id}/nominations/new`.
              Present even when `can_nominate` is false: the two answer different
              questions, so hide the CTA on `can_nominate` (showing `nomination_block_reason`),
              never on the absence of a URL.'
            example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/6/nominations/new
          nominate_api_url:
            type: string
            description: Absolute URL to POST the nomination to — `POST /recognitions/award_cycles/{id}/nominations`.
              Sent so a client that got this card from the browse list never rebuilds
              the path.
            example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/6/nominations
    RecognitionAwardCycleUpcomingCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: A scheduled cycle. Deliberately thinner than an open card — there
          is no CTA, because the window hasn't opened and the submit path would refuse.
        required:
        - opens_in_days
        - can_nominate
        properties:
          opens_in_days:
            type: integer
            description: Whole days until the window opens, floor 0.
            example: 12
          can_nominate:
            type: boolean
            description: Always false in this list.
            example: false
          nomination_block_reason:
            type: string
            example: This award isn't open for nominations right now.
    RecognitionAwardCycleReviewCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: A closed committee cycle THIS caller is asked to decide. Everything
          in this list already passed the membership + recusal test.
        required:
        - can_review
        - my_pick_count
        properties:
          can_review:
            type: boolean
            description: The wider controller gate, which ALSO admits an admin who
              isn't on the committee. True by construction for anything in `needs_my_review`;
              present so a client reads the same key whether it got the cycle from
              this list or built the link itself.
            example: true
          my_pick_count:
            type: integer
            description: How many nominees this member has already recommended — so
              the prompt can distinguish "you haven't started" from "you've submitted
              picks".
            example: 1
          review_url:
            type: string
            description: Absolute URL of the anonymized committee review page.
            example: https://acme.workforce.mangoapps.com/recognition/award-cycles/10/review
    RecognitionAwardCycleDecidedCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: A decided cycle — the "Recent winners" row, expanded with everything
          the web results page shows, so a client can render the row AND its results
          screen from this one response.
        required:
        - decided_at
        - winners
        - winner_count
        - winner_names
        - honor_roll
        - honor_roll_count
        properties:
          decided_at:
            type: string
            format: date-time
            description: When the winners were announced — the date under the trophy
              hero.
          announcement_published:
            type: boolean
            description: Whether the company-wide winner announcement was actually
              published (a decide in "draft" mode leaves it for an admin), so a client
              doesn't imply a broadcast that never went out.
            example: true
          winners:
            type: array
            description: Only the people who actually won. Empty when nobody met the
              bar — a real outcome for a Spotlight cycle whose threshold nobody reached.
            items:
              "$ref": "#/components/schemas/RecognitionAwardCycleWinner"
          winner_count:
            type: integer
            example: 2
          winner_names:
            type: array
            description: The names in display order, for a client that renders the
              web row's one-line sentence ("Alice and Bob"). Always parallel to `winners`.
            items:
              type: string
            example:
            - J.Quack Kolb
            - Weston D'Amore
          honor_roll:
            type: array
            description: EVERYONE who was nominated, winners included — "being nominated
              by a colleague is itself recognition". Never larger than `nominee_count`.
            items:
              "$ref": "#/components/schemas/RecognitionPerson"
          honor_roll_count:
            type: integer
            example: 3
          results_url:
            type: string
            description: Absolute URL of the public results / honor-roll page.
            example: https://acme.workforce.mangoapps.com/recognition/award-cycles/11/results
    RecognitionAwardCycleWinner:
      type: object
      description: One winner of a decided cycle, with the certificate to show them.
      required:
      - user
      - label
      - title
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        label:
          type: string
          description: The caption under the name on the web winner card.
          example: Winner
        title:
          type: string
          description: The award's own title, falling back to "<cycle> — Winner" when
            no Award record exists.
          example: Mango Champions — July — Winner
        certificate:
          allOf:
          - "$ref": "#/components/schemas/RecognitionAwardCycleCertificate"
          nullable: true
          description: "**Nullable.** The decide step logs and continues when an Award
            can't be minted, so a winner with no certificate is a real state. Handle
            the null rather than assuming one is always present."
    RecognitionAwardCycleCertificate:
      type: object
      description: |-
        The printable certificate's fields, in the order the printed page renders them: title, the recipient, who awarded it, the italic citation quote, the program / points / company-value chips, then the footer's issuing organization and date.

        **One block, two endpoints.** This is exactly what `GET /recognitions/awards/{id}/certificate` returns standalone, and what a decided cycle's `winners[].certificate` embeds. Both are produced by one serializer, so a client can render the "View Certificate" overlay from either source and can never get two different certificates for one award.
      required:
      - award_id
      - title
      - recipient
      - points
      - chips
      - unit
      - certificate_url
      - share_url
      properties:
        award_id:
          type: integer
          example: 23
        title:
          type: string
          example: Mango Champions — July — Winner
        recipient:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: The person the certificate is presented to. Carried on the
            block itself — not only on the enclosing winner card — so a client holding
            just this certificate (from a QR scan or a shared link) can render the
            whole thing from it.
        citation:
          type: string
          nullable: true
          description: The italic quote on the certificate — the reason this person
            won.
          example: Recognized through Mango Champions — July.
        awarded_by:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: '**Null when the award was anonymous** — naming the giver is
            exactly the disclosure they opted out of. Automated awards present as
            a "System (Automated)" principal with a null id rather than leaking the
            system account.'
        program:
          type: string
          nullable: true
          example: Spot Recognition
        category:
          type: string
          nullable: true
          example: Teamwork
        company_value:
          type: string
          nullable: true
          example: Teamwork First
        points:
          type: integer
          description: The reward points on the certificate chip. The economy is STORED
            in dollars and PRESENTED in points (1 point = 1¢); this is the already-converted
            figure.
          example: 500
        points_label:
          type: string
          nullable: true
          description: The points chip's label, delimited exactly as the printed certificate
            renders it. **Null when the award carries no points** — the web view prints
            the coins badge only for a positive value, so a recognition-only award
            must not show a "0 pts" chip.
          example: 500 pts
        chips:
          type: array
          description: The certificate's badges, pre-ordered and pre-glyphed as the
            web page paints them (program → points → company value), so the client
            keeps no mapping of its own. Each appears exactly when its source field
            does; the array is empty for an award with no program, points or value
            tag.
          items:
            type: object
            required:
            - key
            - label
            - icon
            properties:
              key:
                type: string
                enum:
                - program
                - points
                - company_value
                example: program
              label:
                type: string
                example: Mango Champions
              icon:
                type: string
                description: Font Awesome class, the same glyph the web badge uses.
                example: fas fa-trophy
        unit:
          type: string
          description: The issuing organization, as the certificate footer prints
            it ("unit · date").
          example: OfficeChat
        awarded_at:
          type: string
          format: date-time
        awarded_on:
          type: string
          description: The award date, pre-formatted as the printed certificate shows
            it.
          example: August 13, 2026
        certificate_url:
          type: string
          description: 'Absolute URL of the **printable** certificate — the print-layout
            page behind Print / Save. (No QR is rendered on it: a scan-to-view square
            reads fine on digital signage but out of place on a formal, often printed,
            certificate.)'
          example: https://acme.workforce.mangoapps.com/recognition/awards/23/card
        share_url:
          type: string
          description: Absolute URL of the award **permalink** — the in-app feed presentation.
            This is what a Share action sends and what the certificate's scan-to-view
            QR should encode. Deliberately a different page from `certificate_url`,
            which would dead-end a phone that scanned it in a print dialog.
          example: https://acme.workforce.mangoapps.com/recognition/awards/23
    RecognitionCertificateMeta:
      type: object
      description: |-
        The printed certificate's fixed copy, sent so the native overlay says what the printed page says instead of hardcoding English strings in the client. Only `GET /recognitions/awards/{id}/certificate` carries it.

        **Copy only — no URLs.** The two the screen needs (`certificate_url` for Print/Save, `share_url` for the QR and Share) live on the certificate itself, where they belong to the award.
      required:
      - eyebrow
      - presented_label
      - awarded_by_prefix
      - scan_label
      properties:
        eyebrow:
          type: string
          description: The gold uppercase line above the award title.
          example: Certificate of Recognition
        presented_label:
          type: string
          description: The line between the award title and the recipient's name.
          example: is proudly presented to
        awarded_by_prefix:
          type: string
          description: Prefix for the giver line, rendered as "awarded by **{name}**".
            Omit the whole line when `awarded_by` is null.
          example: awarded by
        scan_label:
          type: string
          description: The caption under the scan-to-view QR square.
          example: Scan to view
    RecognitionAwardCycleResultsMeta:
      type: object
      description: The Award Results page's own copy and states, sent so the native
        screen says what the web screen says instead of inventing its own wording.
        Only `GET /recognitions/award_cycles/{id}/results` carries it — the list's
        `recent_winners` cards do not.
      required:
      - honor_roll_title
      - honor_roll_note
      - capabilities
      properties:
        announced_label:
          type: string
          description: The web page's subtitle under the cycle name, pre-formatted
            as it prints it. **Absent** when a decided cycle carries no `decided_at`,
            in which case the web page omits the line entirely — omit it too rather
            than printing "announced ".
          example: Winners announced July 13, 2026
        honor_roll_title:
          type: string
          description: The heading over the honor-roll card.
          example: Honor roll — everyone who was nominated
        honor_roll_note:
          type: string
          description: The thank-you line inside the honor-roll card.
          example: Being nominated by a colleague is itself recognition. Thank you,
            all of you.
        empty_state:
          type: object
          description: 'Present **only** when the cycle produced no winners — a real
            outcome for a Spotlight cycle nobody cleared the threshold in, and the
            state this copy describes. The honor roll can still be non-empty. Carries
            no `message`: the web state is a single line.'
          required:
          - key
          - title
          properties:
            key:
              type: string
              example: winners
            title:
              type: string
              example: No winners met the bar this cycle.
        capabilities:
          type: object
          description: The tenant toggles, so one payload shape describes both nomination
            models. `award_cycles_enabled` is always true in a 200 — the endpoint
            403s otherwise.
          properties:
            award_cycles_enabled:
              type: boolean
              example: true
            award_requests_enabled:
              type: boolean
              example: true
    RecognitionProgramCard:
      type: object
      description: 'One recognition program as the in-app card renders it: what it
        is, the viewer''s own standing in it, its remaining points, its categories,
        and whether the viewer may nominate right now.'
      properties:
        id:
          type: integer
          example: 3
        title:
          type: string
          description: The program's name — the card heading.
          example: Above & Beyond
        name:
          type: string
          description: The same value as `title`, under the record's own attribute
            name, so a client matching this row against the dashboard's `active_programs`
            (which names it `name`) doesn't have to know the two keys differ.
          example: Above & Beyond
        slug:
          type: string
          example: above-beyond
        description:
          type: string
          nullable: true
          example: Recognition for going above and beyond job duties
        status:
          type: string
          enum:
          - active
          - inactive
          - draft
          description: Always `active` in this payload — the list serves active programs
            only. Present because the card paints a status pill.
          example: active
        status_label:
          type: string
          description: Display form of `status`, humanized server-side so both surfaces
            agree.
          example: Active
        program_type:
          type: string
          enum:
          - peer_to_peer
          - manager_to_employee
          - achievement_based
          - milestone
          example: achievement_based
        program_type_label:
          type: string
          description: Display form of `program_type` — the card's type chip.
          example: Achievement Awards
        program_type_icon:
          type: string
          description: Font Awesome icon name the web chip uses (users / user-tie
            / trophy / flag-checkered / award), so the native chip carries the same
            glyph instead of maintaining its own mapping.
          example: trophy
        is_automatic:
          type: boolean
          description: True for a system-awarded milestone program. Swap the CTA for
            "Awarded automatically — no nomination needed" and hide the nominations
            stat, exactly as the web card does.
          example: false
        my_awards:
          type: integer
          description: Awards THIS viewer has received in this program. Active awards
            only — a revoked one is not something they hold, which is also how the
            dashboard tile and My Recognition count.
          example: 2
        my_nominations:
          type: integer
          description: Nominations THIS viewer has submitted to this program, in any
            status (pending, approved or rejected). Always 0 for an automatic program.
          example: 1
        starts_on:
          type: string
          format: date-time
          nullable: true
          description: When the nomination window opens. Null means open-ended, the
            common case. Only in-window programs are listed, so a non-null value here
            is always in the past.
          example: '2026-07-01T00:00:00.000Z'
        ends_on:
          type: string
          format: date-time
          nullable: true
          description: When the nomination window closes — for a client that wants
            to say "closes in N days". Null means open-ended.
          example: '2026-09-30T00:00:00.000Z'
        can_nominate:
          type: boolean
          description: Whether this viewer may file a nomination RIGHT NOW — the same
            answer the submit path gives. Hide the Nominate affordance when false.
          example: true
        nomination_block_reason:
          type: string
          nullable: true
          description: Why the CTA isn't offered, phrased for the person reading it.
            Null when `can_nominate` is true. Show it instead of a bare disabled button
            — it names what they can check or who to ask.
          example: Quarterly Star Award is a Manager Recognition program — only managers
            can nominate in it. Recognize a teammate through a peer-to-peer program
            instead.
        reward_points:
          type: object
          description: 'This month''s recognition budget for the program, in the POINTS
            employees see everywhere else in this app. Always present: an unbudgeted
            program (the common case) reports zeros and null dollars rather than omitting
            the block, so a client reads one shape.'
          properties:
            has_budget:
              type: boolean
              description: Whether the program runs a monthly budget at all. Render
                no bar when false.
              example: true
            points_remaining:
              type: integer
              description: Reward points remaining this month. Can be NEGATIVE when
                a program has overspent its allowance — the figure stays honest; only
                `percent_remaining` clamps.
              example: 1000
            total_points:
              type: integer
              description: The program's total monthly reward-point allowance.
              example: 5000
            percent_remaining:
              type: number
              format: float
              description: 0–100, already clamped. Draw the bar from this rather than
                dividing the two figures yourself — an overspent program would otherwise
                produce a negative width.
              example: 20.0
            monthly_budget_dollars:
              type: number
              format: float
              nullable: true
              description: "`total_points` in the stored currency unit, for a consumer
                that reports dollars. Null when the program has no budget."
              example: 50.0
            remaining_budget_dollars:
              type: number
              format: float
              nullable: true
              description: "`points_remaining` in the stored currency unit."
              example: 10.0
        categories:
          type: array
          description: The program's ACTIVE categories, in the tenant's sort order
            — the card's chips, and the choices the nominate form will offer.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 9
              name:
                type: string
                example: Project Completion
              slug:
                type: string
                example: project-completion
              icon:
                type: string
                description: Font Awesome name; falls back to `star` when unconfigured.
                example: check-circle
              color:
                type: string
                description: Hex colour; falls back to `#95a5a6` when unconfigured.
                example: "#27ae60"
        category_chip_limit:
          type: integer
          description: Where the web card collapses the rest into "+N more". Presentation
            guidance, not a rule — a narrower native card may break earlier — and
            `categories` always carries the full list, so expanding never needs a
            second request.
          example: 5
    RecognitionAwardCycleReview:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleReviewCard"
      - type: object
        description: 'The committee review screen for ONE closed committee cycle:
          the `needs_my_review` card it was opened from, plus the anonymized pool,
          the committee roster and this caller''s own ballot. Served whole — no pagination,
          because a submit replaces the entire ballot.'
        required:
        - committee
        - committee_count
        - nominees
        - pool_count
        - my_picks
        - meta
        properties:
          committee:
            type: array
            description: 'Everyone on this cycle''s review committee — the web page''s
              "Review committee: A and B" line, as people rather than a pre-joined
              sentence. Shown so a member sees they aren''t deciding alone. Includes
              the caller.'
            items:
              "$ref": "#/components/schemas/RecognitionPerson"
          committee_count:
            type: integer
            example: 2
          nominees:
            type: array
            description: The pool, highest tally first. Complete — its length IS the
              total.
            items:
              "$ref": "#/components/schemas/RecognitionCycleNominee"
          pool_count:
            type: integer
            description: 'Rows in `nominees`. May be lower than the card''s `nominee_count`
              when somebody nominated has since left the business: they drop out of
              the pool rather than rendering a blank row, though a pick already saved
              for them stays valid.'
            example: 3
          my_picks:
            type: object
            description: This caller's saved ballot for this cycle. Never anyone else's.
            required:
            - nominee_ids
            - count
            - submitted
            properties:
              nominee_ids:
                type: array
                items:
                  type: integer
                example:
                - 893
              count:
                type: integer
                example: 1
              submitted:
                type: boolean
                description: Whether this member has recorded a decision at all —
                  so a client can say "you haven't started" rather than showing an
                  empty ballot that looks the same as a deliberately-cleared one.
                example: true
          meta:
            "$ref": "#/components/schemas/RecognitionAwardCycleReviewMeta"
    RecognitionCycleNominee:
      type: object
      description: 'ONE nominee in the pool: who they are, how many colleagues put
        them forward, and a preview of what those colleagues wrote — never who wrote
        it.'
      required:
      - user
      - nomination_count
      - justifications
      - additional_justification_count
      - picked_by_me
      - is_me
      - can_pick
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        nomination_count:
          type: integer
          description: DISTINCT colleagues who nominated this person — the tally the
            pool is ordered by.
          example: 4
        justifications:
          type: array
          description: 'Anonymized reasons, oldest first, capped at `meta.justification_preview_limit`.
            Text and an opaque id ONLY: there is no nominator here and nothing that
            can be joined back to one.'
          items:
            type: object
            required:
            - id
            - text
            properties:
              id:
                type: integer
                description: Opaque row id, for list keying. It identifies the nomination,
                  never its author.
                example: 43
              text:
                type: string
                example: Christina keeps the team steady when the week goes sideways.
        additional_justification_count:
          type: integer
          description: How many justifications the cap hid — the web row's "…and N
            more nominations" line. 0 when everything fits.
          example: 0
        picked_by_me:
          type: boolean
          description: Whether THIS caller's saved ballot includes this nominee.
          example: true
        is_me:
          type: boolean
          description: The caller's own row. The web renders it disabled with a "You
            — recused" caption.
          example: false
        can_pick:
          type: boolean
          description: False only for the caller's own row — you never vote for yourself,
            and the submit path strips the id even if it is sent. Do not offer a control
            when this is false.
          example: true
    RecognitionAwardCycleReviewMeta:
      type: object
      description: The review page's own copy and the one rule a client must not get
        wrong about the write, sent so the native screen says what the web screen
        says instead of inventing its own wording.
      required:
      - title
      - instructions
      - anonymized
      - justification_preview_limit
      - submit_url
      - submit_label
      - submit_confirm
      - submit_replaces_previous
      - capabilities
      properties:
        title:
          type: string
          example: Committee review — Leadership Award — Q2
        instructions:
          type: string
          description: The web page's subtitle, verbatim.
          example: Nominations are shown anonymously (no nominator names). Pick the
            people you'd recognize — your choices are combined with the rest of the
            committee.
        anonymized:
          type: boolean
          description: 'Always true, and stated rather than implied: a client must
            not render nominator names on this screen, and there are none in the payload
            to render.'
          example: true
        justification_preview_limit:
          type: integer
          description: The cap `justifications` is sliced to. Pair it with `additional_justification_count`
            instead of implying the list is complete.
          example: 4
        submit_url:
          type: string
          description: Absolute URL of this cycle's ballot endpoint.
          example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/10/picks
        submit_label:
          type: string
          example: Save my recommendations
        submit_confirm:
          type: string
          description: The web page's confirmation prompt — it names the replace.
          example: Submit your recommendations for this cycle? This replaces any picks
            you saved earlier.
        submit_replaces_previous:
          type: boolean
          description: 'Always true. The one thing a client must get right about the
            write: it is a replace, not an append. Send the whole selection.'
          example: true
        empty_state:
          type: object
          description: Present **only** when the pool is empty — a committee cycle
            nobody nominated into. The cycle is still legitimately open for review;
            there is simply nothing to decide.
          required:
          - key
          - title
          properties:
            key:
              type: string
              example: nominees
            title:
              type: string
              example: No nominations to review.
        capabilities:
          type: object
          description: The tenant toggles, so a client can hide the same surfaces
            the web nav hides.
          properties:
            award_cycles_enabled:
              type: boolean
              example: true
            award_requests_enabled:
              type: boolean
              example: true
    RecognitionCycleNominationForm:
      type: object
      description: 'The nominate composer: the cycle being nominated into, the form
        to fill, and the page''s own copy.'
      required:
      - cycle
      - form
      - meta
      properties:
        cycle:
          allOf:
          - "$ref": "#/components/schemas/RecognitionAwardCycleOpenCard"
          description: The SAME card `GET /recognitions/award_cycles` serves under
            `open_now`, so a client that arrived from that list renders this screen
            from one model. Read `can_nominate` and `nomination_block_reason` from
            here — a closed or scheduled cycle still renders this screen, it just
            cannot be submitted.
        form:
          type: object
          description: Where to post, what the fields are called, and the bounds the
            model will enforce anyway — sent so a native client can validate before
            a round trip and fail in the same places the server does.
          required:
          - submit_url
          - submit_label
          - nominee_search_url
          - fields
          properties:
            submit_url:
              type: string
              description: Absolute URL to POST the nomination to.
              example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/12/nominations
            submit_label:
              type: string
              example: Submit nomination
            nominee_search_url:
              type: string
              description: 'The typeahead that fills `nominee_id` — `GET /recognitions/employee_suggestions`.
                Named rather than duplicated: this endpoint serves no roster of its
                own.'
              example: https://acme.workforce.mangoapps.com/api/v1/recognitions/employee_suggestions
            fields:
              type: object
              properties:
                nominee_id:
                  type: object
                  properties:
                    label:
                      type: string
                      example: Who are you nominating?
                    placeholder:
                      type: string
                      example: Search for the person you want to nominate...
                    help_text:
                      type: string
                      example: You can't nominate yourself, and one nomination per
                        person per award.
                    required:
                      type: boolean
                      example: true
                description:
                  type: object
                  properties:
                    label:
                      type: string
                      example: Why are you nominating them?
                    hint:
                      type: string
                      example: "(this is the signal — be specific)"
                    placeholder:
                      type: string
                      example: What did they do, and what impact did it have? Tie
                        it to the criteria above.
                    required:
                      type: boolean
                      example: true
                    min_length:
                      type: integer
                      example: 10
                    max_length:
                      type: integer
                      example: 1000
                title:
                  type: object
                  properties:
                    required:
                      type: boolean
                      example: false
                    max_length:
                      type: integer
                      example: 100
                    default:
                      type: string
                      description: What the server stores when `title` is omitted
                        — the cycle's name, truncated to `max_length`. Show it rather
                        than leaving the field blank.
                      example: Employee of the Quarter — Q3
        meta:
          type: object
          description: The page's own copy, so the native screen says what the web
            screen says.
          required:
          - title
          - allows_multiple_nominations
          - capabilities
          properties:
            title:
              type: string
              example: Nominate for Employee of the Quarter — Q3
            subtitle:
              type: string
              nullable: true
              description: The web page's subtitle, same wording and same date format.
              example: Nominations close Sep 30, 2026.
            criteria_label:
              type: string
              description: Present only when the cycle states criteria — the callout's
                label above the form.
              example: 'What we''re looking for:'
            criteria:
              type: string
              description: What a good nomination looks like. Absent (not null) when
                the cycle states none, so a client can test the key itself.
              example: Consistent, quiet leadership.
            allows_multiple_nominations:
              type: boolean
              description: Always true — a cycle POOLS nominations, so this is not
                a one-shot screen. Do NOT hide the CTA after one submit; the same
                nominator may come back for a different colleague. (A second nomination
                of the SAME person is what gets refused.)
              example: true
            capabilities:
              type: object
              properties:
                can_nominate:
                  type: boolean
                  example: true
                award_cycles_enabled:
                  type: boolean
                  example: true
                award_requests_enabled:
                  type: boolean
                  example: true
    RecognitionCycleNominationRequest:
      type: object
      description: 'The nomination. The `nomination` wrapper is optional — the four
        fields may be sent flat. `recognition_program_id` is NOT accepted: a cycle
        nomination belongs to the program running the cycle.'
      properties:
        nomination:
          type: object
          required:
          - nominee_id
          - description
          properties:
            nominee_id:
              type: integer
              description: The colleague being put forward. Must be an active member
                of this tenant, and cannot be the caller.
              example: 893
            description:
              type: string
              minLength: 10
              maxLength: 1000
              description: Why. The signal the cycle is decided on.
              example: Rebuilt the onboarding checklist and cut new-hire ramp by a
                week.
            title:
              type: string
              maxLength: 100
              description: Optional. Defaults to the cycle's name, truncated.
              example: The onboarding rebuild
            is_public:
              type: boolean
              description: Optional. Defaults to the column default (public).
              example: true
    RecognitionCycleNominationResult:
      type: object
      description: The nomination as filed, plus the cycle card with it counted.
      required:
      - id
      - message
      - nominee
      - nominator
      - title
      - description
      - status
      - pooled
      - cycle
      properties:
        id:
          type: integer
          example: 4471
        message:
          type: string
          description: The web flash, verbatim — it names the person and the cycle.
          example: Your nomination for Ada Lovelace has been added to Employee of
            the Quarter — Q3.
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          "$ref": "#/components/schemas/RecognitionPerson"
        title:
          type: string
          example: Employee of the Quarter — Q3
        description:
          type: string
          example: Rebuilt the onboarding checklist and cut new-hire ramp by a week.
        status:
          type: string
          description: Always `pending` on this path, and that is the point — see
            `pooled`.
          enum:
          - pending
          example: pending
        pooled:
          type: boolean
          description: 'Always true. A cycle nomination is POOLED, not reviewed: no
            reviewer is assigned, no Award is minted, and there is no per-nomination
            approve/reject. The cycle decides when it closes. Do NOT render the Model
            A "awaiting approval" affordance against this row.'
          example: true
        submitted_at:
          type: string
          format: date-time
        is_public:
          type: boolean
          example: true
        is_anonymous:
          type: boolean
          description: False unless the tenant allows anonymity. Cycle nominations
            are attributed by default.
          example: false
        cycle:
          allOf:
          - "$ref": "#/components/schemas/RecognitionAwardCycleOpenCard"
          description: The cycle AFTER the write, so `nomination_count`, `nominee_count`,
            `nominated_by_me` and `my_nomination_count` already reflect this submission.
            Re-render the row the client came from off THIS rather than re-fetching
            the list.
    RecognitionCycleNominationRefusal:
      type: object
      description: Every refusal from either verb. `error.details` always names the
        cycle and its RECONCILED state, because two of the refusals are about that
        state and the client's next move is to re-render the card.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - feature_disabled
              - giving_not_allowed
              - cycle_not_open
              - invalid_nominee
              - validation_failed
              - content_rejected
              - nomination_failed
              example: cycle_not_open
            message:
              type: string
              description: The web page's own wording for the same refusal.
              example: That award isn't open for nominations right now.
            details:
              type: object
              properties:
                cycle_id:
                  type: integer
                  example: 12
                status:
                  type: string
                  description: The cycle's EFFECTIVE status, not its stored column
                    — a cycle stored `scheduled` whose window has opened reads `open`
                    here, and an admin-forced close reads `closed` while `closes_at`
                    is still in the future.
                  enum:
                  - scheduled
                  - open
                  - closed
                  - decided
                  - archived
                  example: scheduled
                errors:
                  type: array
                  description: "`validation_failed` / `content_rejected` only — the
                    messages, as one list."
                  items:
                    type: string
                  example:
                  - You've already nominated this person for this award.
                field_errors:
                  type: object
                  description: '`validation_failed` / `content_rejected` only — per-field
                    messages, so a native form can mark the offending field instead
                    of parsing a sentence. A content refusal names the field whose
                    text was rejected, e.g. `{"description": ["contains inappropriate
                    language or terms that aren''t allowed"]}`.'
                  additionalProperties:
                    type: array
                    items:
                      type: string
                  example:
                    nominee:
                    - cannot be the same as the nominator
    RecognitionCyclePicksRequest:
      type: object
      description: The member's COMPLETE ballot for this cycle. Supply `nominee_ids`
        OR `nominee_id` — sending neither is refused with 422 `no_selection` rather
        than read as "clear everything".
      properties:
        nominee_ids:
          description: Every nominee this member recommends. An array of ids, or the
            web's comma-separated string form. `[]` CLEARS the ballot. This replaces
            the saved set — it is not a delta.
          oneOf:
          - type: array
            items:
              type: integer
          - type: string
          example:
          - 893
          - 265
        nominee_id:
          type: integer
          description: Single-pick shorthand — identical to sending a one-element
            `nominee_ids`, including the replace. Ignored when `nominee_ids` is present.
          example: 893
    RecognitionCyclePicksResult:
      type: object
      description: The member's ballot AFTER the write. Re-render from this, not from
        what was sent.
      required:
      - cycle_id
      - cycle_name
      - message
      - picked_ids
      - picked_count
      - picked
      - ignored_ids
      - previous_picked_ids
      - changed
      properties:
        cycle_id:
          type: integer
          example: 10
        cycle_name:
          type: string
          example: Leadership Award — Q2
        message:
          type: string
          description: The web flash, verbatim — including the honest empty case,
            which says "cleared" rather than "saved".
          example: Your recommendations for Leadership Award — Q2 were saved.
        picked_ids:
          type: array
          items:
            type: integer
          description: The saved ballot. Authoritative.
          example:
          - 893
          - 265
        picked_count:
          type: integer
          example: 2
        picked:
          type: array
          description: The people behind `picked_ids`, name-ordered, for an immediate
            toast or summary.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        ignored_ids:
          type: array
          items:
            type: integer
          description: Ids that were sent but could not be picked — not in this cycle's
            pool, or the caller themselves. Empty on a well-formed call; a non-empty
            one means the client's list is stale.
          example: []
        previous_picked_ids:
          type: array
          items:
            type: integer
          description: The ballot this call replaced.
          example:
          - 893
        changed:
          type: boolean
          description: False when the submitted ballot matched the saved one — skip
            the "saved" toast on a no-op re-submit.
          example: true
        review_url:
          type: string
          description: Absolute URL of the web review page for this cycle.
          example: https://acme.workforce.mangoapps.com/recognition/award-cycles/10/review
    RecognitionCycleReviewRefusal:
      type: object
      description: A committee-review refusal. The message is the WEB page's own flash
        wording — both surfaces read it from one place, so they cannot drift.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - feature_disabled
              - not_committee_cycle
              - not_committee_member
              - cycle_not_closed
              - recused
              example: recused
            message:
              type: string
              example: You're nominated in this cycle, so you can't take part in the
                decision.
            details:
              type: object
              description: Present on the four gate refusals (not on `access_denied`
                / `feature_disabled`, which are about the tenant, not the cycle).
              properties:
                cycle_id:
                  type: integer
                  example: 10
                status:
                  type: string
                  enum:
                  - scheduled
                  - open
                  - closed
                  - decided
                  - archived
                  example: closed
    RecognitionReactionState:
      type: object
      description: One recognition's reaction state AFTER a write — the same keys
        the `engagement` block carries on `GET /recognitions/feed` and `GET /recognitions/posts/{id}`,
        so a client patches the card it already holds instead of re-fetching the screen.
      required:
      - item_type
      - item_id
      - emoji
      - reacted
      - reactions_count
      - reaction_summary
      - my_reactions
      properties:
        item_type:
          type: string
          enum:
          - recognition_post
          - award
          - recognition_comment
          description: The CANONICAL type, even when the request used an alias.
          example: recognition_post
        item_id:
          type: integer
          example: 4471
        emoji:
          type: string
          description: The glyph this call toggled.
          example: "\U0001F389"
        reacted:
          type: boolean
          description: True when this call ADDED the reaction (HTTP 201), false when
            it removed one (HTTP 200).
          example: true
        reactions_enabled:
          type: boolean
          description: The tenant's reaction switch, so a client can retire the bar
            in step with it.
          example: true
        reactions_count:
          type: integer
          description: Every reaction on this recognition, from every person.
          example: 7
        reaction_summary:
          type: array
          description: Grouped counts, most-reacted first (the glyph breaks a tie).
          items:
            type: object
            properties:
              emoji:
                type: string
                example: "\U0001F44D"
              count:
                type: integer
                example: 4
        my_reaction:
          type: string
          nullable: true
          description: The caller's first emoji on this recognition — the single-glyph
            convenience the feed and detail cards render. Null when they hold none.
          example: "\U0001F389"
        my_reactions:
          type: array
          description: EVERY emoji this caller holds on this recognition. A viewer
            may hold several at once, each toggled independently.
          items:
            type: string
          example:
          - "\U0001F389"
          - "\U0001F525"
        unread_notification_count:
          type: integer
          example: 3
    RecognitionReactor:
      type: object
      description: One reaction with the person who left it, as `GET /recognitions/reactions`
        pages them.
      properties:
        id:
          type: integer
          example: 90210
        emoji:
          type: string
          example: "\U0001F389"
        label:
          type: string
          description: The friendly name for the glyph, for tooltips and screen readers.
          example: Celebrate
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        reacted_at:
          type: string
          format: date-time
    RecognitionReactionSummary:
      type: object
      description: |-
        The per-emoji rollup a reactor sheet's tabs are built from, shipped alongside the paged list on `GET /recognitions/reactions` and `GET /recognitions/comments/{id}/reactions`.
        Computed over the **whole set**, not the page, and **not** narrowed by `?emoji=` — the tabs must keep showing the glyphs the reader can switch *to*, and their counts must not shrink as the reader pages. Two aggregate queries, so a thousand reactions cost the same as three.
        Same keys as the `engagement` block on `GET /recognitions/feed` and `GET /recognitions/posts/{id}`, so a client has one shape to parse.
      required:
      - reactions_count
      - reaction_summary
      - my_reactions
      - reactions_enabled
      properties:
        reactions_count:
          type: integer
          description: Every reaction on this item, from every person.
          example: 7
        reaction_summary:
          type: array
          description: Grouped counts, most-reacted first (the glyph breaks a tie).
          items:
            type: object
            properties:
              emoji:
                type: string
                example: "\U0001F44D"
              count:
                type: integer
                example: 4
        my_reaction:
          type: string
          nullable: true
          description: The caller's first emoji on this item — the single-glyph convenience
            the feed and detail cards render. Null when they hold none.
          example: "\U0001F389"
        my_reactions:
          type: array
          description: EVERY emoji this caller holds on this item.
          items:
            type: string
          example:
          - "\U0001F389"
          - "\U0001F525"
        reactions_enabled:
          type: boolean
          description: The tenant's reaction switch. The list itself is deliberately
            NOT gated on it (turning reactions off retires the affordance, it does
            not retract what people left), so this is how a client reading only this
            endpoint knows whether to render the bar.
          example: true
    RecognitionReactionsPage:
      type: object
      description: The page envelope on `GET /recognitions/reactions`.
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total_count:
          type: integer
          example: 7
        total_pages:
          type: integer
          example: 1
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
    RecognitionAcknowledgement:
      type: object
      description: 'The answer from `POST /recognitions/{posts,awards}/{id}/acknowledge`.
        Always a `200`: "you are not the person recognized" is the ordinary case on
        a shared card, not an error.'
      required:
      - type
      - id
      - acknowledged
      - newly_acknowledged
      properties:
        type:
          type: string
          enum:
          - recognition_post
          - award
          description: The same vocabulary `unacknowledged_recognitions` emits on
            `GET /api/v1/apps`, so the `type` a client is given is the one it reads
            back.
          example: recognition_post
        id:
          type: integer
          description: The row that was stamped. For a GROUP give this is the caller's
            OWN sibling row, which may differ from the id in the path.
          example: 4471
        acknowledged:
          type: boolean
          description: Whether this recognition is settled — `true` for a repeat call,
            and `false` when the caller is not the person recognized (nothing was
            written).
          example: true
        newly_acknowledged:
          type: boolean
          description: 'Whether THIS call settled it. The flag to key the animation
            off: a re-opened card never re-celebrates.'
          example: true
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          description: When it was acknowledged; `null` when nothing was written.
          example: '2026-08-18T09:14:22Z'
        reason:
          type: string
          enum:
          - not_recipient
          - not_active
          - invalid_item
          - error
          description: Present only when `acknowledged` is `false`. DIAGNOSTIC, never
            a message to show a user — `not_recipient` is the ordinary answer whenever
            somebody other than the person recognized opens a card.
          example: not_recipient
    RecognitionFeedItem:
      type: object
      description: One recognition card. The identity half (type, id, title, message,
        points, people, program, value, timestamp) is shared with the dashboard's
        activity rows; the rest is what the browsable feed card renders on top.
      properties:
        type:
          type: string
          enum:
          - award
          - recognition_post
          description: "`award` is a formal program award; `recognition_post` is peer
            recognition. Clients use this to pick the card treatment."
        id:
          type: integer
          example: 8814
        title:
          type: string
          nullable: true
          description: Award title. Null for peer posts, which lead with the message.
          example: Excellence Award
        message:
          type: string
          nullable: true
          description: The recognition text — an award's description, or a post's
            content.
          example: Thank you for the incredible help on the release deadline.
        points:
          type: integer
          description: Reward points the RECIPIENT earned. An award stores dollars
            and converts; a peer post stores points directly. Always present (0 when
            none).
          example: 250
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        group_recipients:
          type: array
          nullable: true
          description: Present only on a collapsed GROUP give — every recipient of
            the one recognition, so a client can render "Alice, Bob and 3 others".
            Null for an ordinary single-recipient card.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Null when `anonymous` is true. For an automated lifecycle award
            this is the "System (Automated)" pseudo-person with a null id — the system
            principal is never exposed.
        anonymous:
          type: boolean
          description: The giver chose to stay unattributed; `giver` is null.
          example: false
        program:
          type: string
          nullable: true
          example: Spot Award
        category:
          type: string
          nullable: true
          example: Project Completion
        company_value:
          type: string
          nullable: true
          description: The company value this recognition celebrates.
          example: Teamwork
        occurred_at:
          type: string
          format: date-time
          description: When the recognition happened (an award's awarded_at, a post's
            published_at).
        visibility:
          type: string
          nullable: true
          enum:
          - public
          - department
          - team
          - private
          description: Audience of a peer post. Null for awards, which are public
            by definition in this feed.
        tags:
          type: array
          description: Free-form recognition tags. Always an array (empty when none).
          items:
            type: string
          example:
          - teamwork
          - excellence
        photo_url:
          type: string
          nullable: true
          description: Absolute URL of the photo the giver attached, as a resized
            WebP rendition (never the original upload). Null when there is none.
        award_art_url:
          type: string
          nullable: true
          description: Absolute URL of the award-card artwork, when one was chosen.
        engagement:
          type: object
          description: Reaction / comment / share counts, plus the caller's own reaction.
          properties:
            reactions_count:
              type: integer
              example: 4
            reactions_enabled:
              type: boolean
              description: Tenant setting — hide the reaction affordance when false.
            my_reaction:
              type: string
              nullable: true
              description: The emoji the caller reacted with, or null.
              example: "\U0001F389"
            reaction_summary:
              type: array
              description: Per-emoji totals, most-used first.
              items:
                type: object
                properties:
                  emoji:
                    type: string
                    example: "\U0001F389"
                  count:
                    type: integer
                    example: 3
            comments_count:
              type: integer
              example: 2
            comments_enabled:
              type: boolean
              description: Tenant setting — hide the comment affordance when false.
            shares_count:
              type: integer
              example: 0
        boost:
          type: object
          nullable: true
          description: Pile-on points — teammates adding their own allowance to someone
            else's recognition. **Null for awards**, which cannot be boosted, so clients
            should treat null as "no boost affordance" rather than zero.
          properties:
            total_points:
              type: integer
              description: Points teammates have piled on so far.
              example: 40
            boosted_by_me:
              type: boolean
              description: A boost is one per person; true means the caller already
                did.
            can_boost:
              type: boolean
              description: False on the caller's own give or receipt, when they have
                already boosted, when peer points are off, or when their remaining
                allowance covers none of the offered amounts.
            amounts:
              type: array
              description: Amounts the server will actually accept, already filtered
                to the caller's remaining allowance. Empty when they cannot boost.
              items:
                type: integer
              example:
              - 5
              - 10
              - 25
        permissions:
          type: object
          description: What this caller may do with this item, matching what the server
            would allow. This is where an admin's feed differs from an employee's
            — everything else is identical.
          properties:
            can_delete:
              type: boolean
              description: True for the post's author, a business admin, or a Recognitions
                app admin.
            can_edit:
              type: boolean
              description: 'What PATCH /recognitions/posts|awards/{id} would accept:
                the author or a moderator on a post, a moderator on an award, and
                in both cases only while the recognition is still active. NOT the
                same gate as can_delete — a held post is deletable but not editable,
                and an award is editable by a moderator while can_delete is withheld
                on a feed row.'
            can_share:
              type: boolean
              description: Public, non-anonymous, active items only.
            can_comment:
              type: boolean
            can_react:
              type: boolean
            can_boost:
              type: boolean
              description: Mirrors `boost.can_boost`; always false for awards.
    RecognitionFeedMeta:
      type: object
      description: Paging state, the active filter, and viewer-level capabilities.
      properties:
        filter:
          type: string
          enum:
          - all
          - awards
          - posts
          - my_team
          - my_department
          description: The filter actually applied (an unknown value falls back to
            `all`).
        available_filters:
          type: array
          description: Every filter this endpoint accepts, in the order the web page
            shows them.
          items:
            type: string
          example:
          - all
          - awards
          - posts
          - my_team
          - my_department
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          description: "**The authoritative paging signal — always exact.** Page until
            this is false; do not drive paging from the totals below."
        has_prev_page:
          type: boolean
        total_count:
          type: integer
          description: Number of items in the loaded window after filtering. The feed
            merges two tables and applies some filters in memory, so an exact total
            would mean loading everything — read `total_count_exact` before showing
            this.
          example: 37
        total_count_exact:
          type: boolean
          description: True when `total_count` is the real total; false when it is
            only a lower bound because the window was truncated. **Do not render "of
            N" unless this is true.**
        total_pages:
          type: integer
          nullable: true
          description: Null whenever `total_count_exact` is false — a null instead
            of a confidently wrong page count.
          example: 2
        capped_at:
          type: integer
          description: Maximum rows read per source. The feed will not page beyond
            this many items even if more exist in the 30-day window.
          example: 200
        capabilities:
          type: object
          description: Viewer-level state the client needs to render the feed's global
            actions, so it never offers something the server would refuse.
          properties:
            can_give_recognition:
              type: boolean
              description: Whether this caller may give peer recognition at all.
            boosting_available:
              type: boolean
              description: Peer points are enabled AND this caller may give.
            giving_remaining:
              type: integer
              description: Points left in the caller's monthly giving allowance.
              example: 120
            reactions_enabled:
              type: boolean
            comments_enabled:
              type: boolean
    RecognitionLeaderboardRow:
      type: object
      required:
      - rank
      - user_id
      - user
      - name
      - count
      - is_me
      properties:
        rank:
          type: integer
          description: '1-based POSITION in this board — what the medallion renders
            (gold #1, silver #2, bronze #3, neutral from #4). Not the competition-style
            rank in `my_standing`: tied rows occupy consecutive positions here.'
          example: 1
        user_id:
          type: integer
          example: 1033
        user:
          type: object
          nullable: true
          description: Null when the person has left the business since being recognized.
            The row is KEPT (with `name` degrading to "Unknown") rather than vanishing
            and shifting every rank below it.
          properties:
            id:
              type: integer
              example: 1033
            name:
              type: string
              example: Weston D'Amore
            title:
              type: string
              nullable: true
              description: Job title. The native row shows title, falling back to
                department.
              example: Area Manager
            department:
              type: string
              nullable: true
              example: Sales Department
            image:
              type: string
              nullable: true
              description: Absolute avatar URL.
        name:
          type: string
          description: The display name for the row. "Unknown" when `user` is null,
            so the row still renders.
          example: Weston D'Amore
        count:
          type: integer
          description: Recognition received (recipients) or given (givers) in the
            window, under the reported `counting_basis`.
          example: 6
        is_me:
          type: boolean
          description: Whether this row is the caller — the "You" badge.
          example: false
    RecognitionLeaderboardStanding:
      type: object
      required:
      - rank
      - count
      - in_top
      - ranked_total
      properties:
        rank:
          type: integer
          nullable: true
          description: 'Competition-style 1-based rank against the FULL board — ties
            SHARE a rank. Null when the caller has no activity in the window: unranked,
            not last. Render nothing rather than a zero row.'
          example: 7
        count:
          type: integer
          description: The caller's own count in the window. 0 when unranked.
          example: 1
        in_top:
          type: boolean
          description: 'Whether the caller already appears in the returned rows. When
            false and `rank` is present, pin a "You — #N" footer row; when true, don''t,
            or it duplicates a row already drawn.'
          example: false
        ranked_total:
          type: integer
          description: How many people appear on the full board — the denominator
            behind `rank`, so "#7" can be shown as "#7 of 19" without a second request.
          example: 19
    RecognitionTeamCountTile:
      type: object
      description: One of the two count tiles. `total` IS `awards + shout_outs` —
        render the total large and the split as the sub-line, not three separate numbers.
      properties:
        total:
          type: integer
          example: 34
        awards:
          type: integer
          description: Formal awards (dollar-denominated on the record).
          example: 12
        shout_outs:
          type: integer
          description: Peer recognition posts. Every post counts, including 0-point
            ones — a tenant not using redeemable rewards collects no points at all.
          example: 22
    RecognitionTeamGivingPool:
      type: object
      description: One pool the viewer can give from. Points are pre-converted, so
        a client never needs the tenant's points-per-dollar rate.
      properties:
        label:
          type: string
          example: Spot Awards
        sublabel:
          type: string
          example: Manager to employee budget
        kind:
          type: string
          enum:
          - allowance
          - budget
          description: "`allowance` is the monthly peer-giving pool; `budget` is an
            annual award-program allocation. The two never draw on each other."
          example: budget
        available_points:
          type: integer
          example: 1500
        allocated_points:
          type: integer
          example: 5000
        percent_remaining:
          type: integer
          description: "`available / allocated`, clamped to 0..100 — a manager who
            overspent a budget must not drive a negative-width progress bar."
          example: 30
        period:
          type: string
          description: The window the allocation covers — an allowance resets monthly,
            a budget annually.
          example: this year
    RecognitionTeamPendingNomination:
      type: object
      properties:
        id:
          type: integer
          example: 88
        title:
          type: string
          example: Renewal save of the quarter
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          "$ref": "#/components/schemas/RecognitionPerson"
        program:
          type: string
          nullable: true
          example: Spot Awards
        status:
          type: string
          example: pending
        submitted_at:
          type: string
          format: date-time
    RecognitionApprovalsPageMeta:
      type: object
      description: Kaminari paging state for one of the two approvals lists. Counts
        are exact (each list is one business-scoped relation), so `total_pages` is
        always a real number and `has_next_page` is authoritative.
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total_count:
          type: integer
          example: 37
        total_pages:
          type: integer
          example: 2
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    RecognitionApprovalNomination:
      type: object
      description: One nomination awaiting the reviewer's decision.
      properties:
        id:
          type: integer
          example: 88
        title:
          type: string
          example: Renewal save of the quarter
        description:
          type: string
          nullable: true
          example: Turned a churn risk into a multi-year renewal.
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Withheld (null) when the nomination is anonymous.
        anonymous:
          type: boolean
          description: True when the nominator is withheld.
        program:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 5
            name:
              type: string
              example: Spot Awards
        category:
          type: object
          nullable: true
          description: The award category, with the chip's icon/color fallbacks. Null
            when none is set.
          properties:
            id:
              type: integer
              example: 12
            name:
              type: string
              example: Above and Beyond
            icon:
              type: string
              example: fa-star
            color:
              type: string
              example: "#f59e0b"
        requested_points:
          type: integer
          nullable: true
          description: The requested award value in reward points (dollars converted
            server-side). Null when no value was requested.
          example: 250
        status:
          type: string
          example: pending
          description: "`pending` or `under_review`."
        multi_level_approval:
          type: boolean
          description: True when the program needs more than one approval level.
        approval_progress:
          type: object
          description: Which approval step this nomination is on, of how many.
          properties:
            current:
              type: integer
              example: 1
            required:
              type: integer
              example: 1
        submitted_at:
          type: string
          format: date-time
          nullable: true
        supporting_documents:
          type: object
          description: The reviewer's evidence. `count` is the ledger size; `files`
            reads through to the live blobs; `missing_count` is ledger rows whose
            bytes are gone (surfaced so a shrinking list is visible, not silent).
          properties:
            count:
              type: integer
              example: 2
            missing_count:
              type: integer
              example: 0
            files:
              type: array
              items:
                type: object
                properties:
                  filename:
                    type: string
                    example: renewal-contract.pdf
                  content_type:
                    type: string
                    example: application/pdf
                  byte_size:
                    type: integer
                    example: 84213
                  icon:
                    type: string
                    example: fa-file-pdf
                  download_url:
                    type: string
                    description: Absolute, attachment-disposition download URL for
                      the blob.
    RecognitionApprovalPost:
      type: object
      description: One peer recognition post held for manager approval.
      properties:
        id:
          type: integer
          example: 401
        message:
          type: string
          example: Thanks for covering the late shift.
        author:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Withheld (null) when the post is anonymous.
        anonymous:
          type: boolean
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        points:
          type: integer
          description: The peer points attached to the post (already in points; 0
            when none).
          example: 500
        has_award_art:
          type: boolean
          description: The post carries illustrated award art (an Asset Library card).
        has_attachments:
          type: boolean
          description: The post carries a photo or file attachments.
        company_value:
          type: string
          nullable: true
          example: Customer First
        reviewer:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: The reviewer the post routes to (useful when an admin views
            the whole tenant's held posts).
        submitted_at:
          type: string
          format: date-time
    RecognitionTeamGapRow:
      type: object
      description: A team member nobody has recognized lately.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        days_since_recognition:
          type: integer
          nullable: true
          description: Null when this person has NEVER been recognized. Render "Never
            recognized" for that case — never "0 days ago".
          example: 47
        last_recognized_at:
          type: string
          format: date-time
          nullable: true
          description: Null alongside a null `days_since_recognition`.
        never_recognized:
          type: boolean
          description: True exactly when `days_since_recognition` is null.
          example: false
    RecognitionTeamAnniversary:
      type: object
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          nullable: true
          example: Support Specialist
        department:
          type: string
          description: The person's department, or "Unassigned".
          example: Customer Success
        years:
          type: integer
          description: Years of service they are about to complete. Always >= 1.
          example: 5
        milestone_label:
          type: string
          description: Ready-to-render label — "Decade", "Silver (25)", "3 Years".
          example: 5 Years
        anniversary_date:
          type: string
          format: date
        days_until:
          type: integer
          description: 0 means today.
          example: 12
        recognized:
          type: boolean
          description: Whether their years-of-service award was already given this
            year. The web hides the Celebrate button when true — offering it again
            invites a duplicate award.
          example: false
    RecognitionAnniversaryRosterRow:
      type: object
      description: ONE roster row — the same information the web table prints, minus
        its link chrome. Richer than `RecognitionTeamAnniversary` (the Team screen's
        teaser), which carries no hire date, tier, status or action flag.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          description: 'The resolved title the row prints: the person''s job title,
            falling back to their org role and then "No title". (`user.title` is the
            raw column and may be null.)'
          example: Support Specialist
        department:
          type: string
          description: The person's department, or "Unassigned".
          example: Customer Success
        hire_date:
          type: string
          format: date
          description: The date the anniversary counts from — their original hire
            date when one is recorded, so a rehire keeps their full service length.
        anniversary_date:
          type: string
          format: date
          description: The upcoming anniversary itself, inside the applied window.
        years:
          type: integer
          description: Years of service they are about to complete. Always >= 1.
          example: 5
        milestone_label:
          type: string
          description: Ready-to-render label — "Decade", "Silver (25)", "3 Years".
          example: 5 Years
        milestone_tier:
          type: string
          enum:
          - early_career
          - established
          - veteran
          - senior
          - legendary
          description: The service-length band, so a client can tint the year chip
            the way the web badge and its legend do without re-deriving the boundaries
            (1-4 / 5-9 / 10-19 / 20-24 / 25+).
          example: established
        milestone_tier_label:
          type: string
          description: The band's display name, matching the web legend.
          example: Established
        days_until:
          type: integer
          description: 0 means today. **Signed** — negative when `from_date` is in
            the past, so render "3 days ago" rather than an impossible countdown.
          example: 12
        is_upcoming_soon:
          type: boolean
          description: Within a week and not yet past — the rows the web table tints
            green.
          example: false
        recognized:
          type: boolean
          description: Whether their years-of-service award was already given this
            year.
          example: false
        status:
          type: string
          enum:
          - pending
          - recognized
          - rewarded
          description: The web badge's three states. `rewarded` means the gift/fulfilment
            has actually gone out, which is a step beyond `recognized`.
          example: pending
        rewarded_at:
          type: string
          format: date-time
          nullable: true
          description: Null unless `status` is `rewarded`.
        reward_details:
          type: string
          nullable: true
          description: What was sent, when the tenant recorded it.
          example: Engraved watch
        can_recognize:
          type: boolean
          description: |-
            Whether to offer the Recognize / Celebrate action on THIS row — the tenant allows instant awards AND this milestone hasn't been awarded yet. Offering it on an already-recognized row invites a duplicate award, which is why the web hides it there too.

            The action is `POST /recognitions/quick_award`, and it must carry `anniversary_years` set to this row's `years` — that is what records the recognition and flips this row to `recognized: true`.
          example: true
    RecognitionTeamHighlight:
      type: object
      nullable: true
      description: Null when the team has no recognition at all. `count` agrees with
        that person's matching column in `team_members`.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        count:
          type: integer
          example: 12
    RecognitionTeamActivityRow:
      type: object
      description: One recognition, normalized across formal awards and peer shout-outs
        so the list renders through one row shape.
      properties:
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        group_recipients:
          type: array
          nullable: true
          description: Present only on a collapsed GROUP give — one recognition fanned
            out to several people arrives as ONE row naming all of them, because rendering
            each separately let a single ten-person shout-out fill the whole list.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        recipient_label:
          type: string
          description: '"Alice, Bob and Carol" for a collapsed group give, the single
            recipient''s name otherwise — so no client-side name joining is needed.'
          example: Ada Lovelace and Bo Diaz
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Null when `anonymous` is true — anonymity is enforced server-side,
            not merely hidden by the client.
        anonymous:
          type: boolean
          example: false
        program:
          type: string
          description: The award program's name, or "Peer Recognition" for a shout-out.
          example: Peer Recognition
        message:
          type: string
          nullable: true
          description: The award title or the shout-out body.
        points:
          type: integer
          description: The recognition's value in reward points. 0 for a pointless
            shout-out.
          example: 250
        occurred_at:
          type: string
          format: date-time
    RecognitionTeamMemberRow:
      type: object
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          nullable: true
          description: Their own job title, falling back to their organizational role.
          example: Support Specialist
        received_count:
          type: integer
          description: All-time awards + shout-outs received, under the tenant-wide
            counting rules.
          example: 8
        given_count:
          type: integer
          example: 3
        this_month_count:
          type: integer
          description: Received in the current CALENDAR month — always a subset of
            `received_count`.
          example: 2
    RecognitionProfileSubject:
      type: object
      description: The person whose recognition profile this is. Same shape as a `team_members`
        row from `/recognitions/team`, so the roster and the detail screen are one
        client model.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          nullable: true
          description: Their own job title, falling back to their organizational role
            in THIS business. Null when neither is set — the web renders the generic
            "Team Member" for that case.
          example: Print Operator
    RecognitionProfileMonthTile:
      type: object
      description: One "This Month" tile, over the current CALENDAR month. The total
        is a roll-up of BOTH recognition tables, matching the "This Month" column
        on the roster that links here; the split is published so a client can render
        the sub-line without a second request.
      properties:
        total:
          type: integer
          description: awards + shout_outs.
          example: 20
        awards:
          type: integer
          example: 5
        shout_outs:
          type: integer
          example: 15
    RecognitionProfileSection:
      type: object
      description: One of the profile's three lists. Not paginated — the latest `limit`
        rows, newest first, plus the full `total_count`, which together are what the
        web prints as "Showing latest 20 of N". Use `/recognitions/feed` for deeper
        history.
      properties:
        total_count:
          type: integer
          description: Every row that matches, before the limit — so a client can
            say "20 of 47" rather than implying the list is complete.
          example: 47
        showing:
          type: integer
          description: Rows actually in `items`.
          example: 20
        limit:
          type: integer
          description: The server's per-list cap.
          example: 20
        has_more:
          type: boolean
          description: "`total_count` exceeds `limit`."
          example: true
        items:
          type: array
          items:
            "$ref": "#/components/schemas/RecognitionProfileRow"
    RecognitionProfileRow:
      type: object
      description: One recognition, normalized across formal awards and peer shout-outs
        so all three lists render through one row shape. This is the same identity
        half as `RecognitionFeedItem` (and the dashboard's activity rows) — the same
        server-side serializer — minus the browsable feed's own engagement, boost
        and permission blocks. Open the full detail with `GET /recognitions/awards/{id}`
        or `GET /recognitions/posts/{id}` using `type` to pick the path.
      properties:
        type:
          type: string
          enum:
          - award
          - recognition_post
          description: "`award` is a formal program award; `recognition_post` is peer
            recognition. Also selects the detail path for this row."
        id:
          type: integer
          example: 8814
        title:
          type: string
          nullable: true
          description: Award title. Null for peer posts, which lead with the message.
          example: Excellence Award
        message:
          type: string
          nullable: true
          description: The recognition text — an award's description, or a post's
            full content. The web truncates it at 150 characters; the API sends it
            whole so the client decides.
        points:
          type: integer
          description: Reward points the RECIPIENT earned. An award stores dollars
            and converts; a peer post stores points directly. Always present (0 when
            none) — the web hides the badge for 0.
          example: 250
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        group_recipients:
          type: array
          nullable: true
          description: Present only on a collapsed GROUP give — every recipient of
            the one recognition. Null for an ordinary single-recipient row.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Null when `anonymous` is true — anonymity is enforced server-side,
            not merely hidden by the client. For an automated lifecycle award this
            is the "System (Automated)" pseudo-person with a null id; the system principal
            is never exposed. On the `awards_given` list this is the profile's subject.
        anonymous:
          type: boolean
          description: The giver chose to stay unattributed; `giver` is null.
          example: false
        program:
          type: string
          nullable: true
          description: The award program's name. Null for a peer shout-out.
          example: Spot Recognition
        category:
          type: string
          nullable: true
          example: Project Completion
        company_value:
          type: string
          nullable: true
          description: The company value this recognition celebrates.
          example: Teamwork
        occurred_at:
          type: string
          format: date-time
          description: When the recognition happened — an award's awarded_at, a post's
            published_at. Every list is ordered by this, newest first.
    RfpAnswerInput:
      type: object
      properties:
        question:
          type: string
        answer:
          type: string
        category:
          type: string
          enum:
          - technical
          - compliance
          - commercial
          - delivery
          - other
        tags:
          type: array
          items:
            type: string
        active:
          type: boolean
    RfpAnswer:
      type: object
      properties:
        id:
          type: integer
        question:
          type: string
        answer:
          type: string
        category:
          type: string
        tags:
          type: array
          items:
            type: string
        active:
          type: boolean
        stale:
          type: boolean
          description: Not reviewed (or edited) within the workspace's staleness window
            (Settings > Answer freshness, 12 months by default)
        usage_count:
          type: integer
        last_used_at:
          type: string
          format: date-time
          nullable: true
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
        owner:
          "$ref": "#/components/schemas/RfpPerson"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RfpPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    RfpResponseSummary:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - draft
          - on_hold
          - in_review
          - approved
          - submitted
          - declined
        status_label:
          type: string
        locked:
          type: boolean
          description: Approved or submitted — section drafts refuse edits
        due_on:
          type: string
          format: date
          nullable: true
        responsible:
          "$ref": "#/components/schemas/RfpPerson"
        section_count:
          type: integer
        open_gap_count:
          type: integer
        assessment_id:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RfpResponse:
      allOf:
      - "$ref": "#/components/schemas/RfpResponseSummary"
      - type: object
        properties:
          notes:
            type: string
            nullable: true
          internal_target_on:
            type: string
            format: date
            nullable: true
          approved_at:
            type: string
            format: date-time
            nullable: true
          submitted_at:
            type: string
            format: date-time
            nullable: true
          sections:
            type: array
            items:
              "$ref": "#/components/schemas/RfpSection"
    RfpSection:
      type: object
      properties:
        id:
          type: integer
        position:
          type: integer
        kind:
          type: string
          description: requirement, or a narrative kind (cover letter, executive summary…)
        requirement:
          type: string
        category:
          type: string
          nullable: true
        mandatory:
          type: boolean
        draft:
          type: string
          nullable: true
        grounded_on:
          type: string
          nullable: true
          enum:
          - library
          - human
          - capabilities
          - knowledge
          - none
        grounding:
          type: string
          description: Human-readable provenance
        needs_input:
          type: boolean
        reviewed:
          type: boolean
        owner:
          "$ref": "#/components/schemas/RfpPerson"
        updated_at:
          type: string
          format: date-time
    RfpAssessmentSummary:
      type: object
      properties:
        id:
          type: integer
        issuer:
          type: string
          nullable: true
        recommendation:
          type: string
          nullable: true
          enum:
          - go
          - conditional
          - no_bid
          description: null when the assessment ran without a capability profile or
            found no requirements
        score:
          type: integer
          nullable: true
        hard_gated:
          type: boolean
        terms_screened:
          type: boolean
          nullable: true
        response_id:
          type: integer
          nullable: true
          description: The RFP response drafted from this assessment, if any
        run_by:
          type: string
          description: Email of the person who ran it
        created_at:
          type: string
          format: date-time
    RfpAssessment:
      allOf:
      - "$ref": "#/components/schemas/RfpAssessmentSummary"
      - type: object
        properties:
          company_name:
            type: string
            nullable: true
          rfp_meta:
            type: object
            properties:
              issuer:
                type: string
                nullable: true
              estimated_value:
                type: string
                nullable: true
              submission_deadline:
                type: string
                nullable: true
          no_profile:
            type: boolean
          no_requirements:
            type: boolean
          input_truncated:
            type: boolean
          breakdown:
            type: array
            items:
              type: object
          requirements:
            type: array
            items:
              type: object
              properties:
                text:
                  type: string
                category:
                  type: string
                mandatory:
                  type: boolean
                we_can_meet:
                  type: string
                evidence:
                  type: string
          unmet_mandatory:
            type: array
            items:
              type: object
          disqualifier_hits:
            type: array
            items:
              type: object
    MyTicketsWidgetResponse:
      type: object
      required:
      - success
      - user_role
      - tabs
      properties:
        success:
          type: boolean
          example: true
        user_role:
          type: string
          enum:
          - member
          - agent
          - manager
          - admin
          - super_admin
          description: Effective role of the current user in this business
        tabs:
          type: array
          description: Ordered array of applicable tabs (role-dependent)
          items:
            "$ref": "#/components/schemas/TicketTab"
    TicketTab:
      type: object
      required:
      - key
      - label
      - total_count
      - tickets
      properties:
        key:
          type: string
          enum:
          - created_by_me
          - assigned_to_me
          - pending_approval_for_me
          description: Machine-readable tab identifier
        label:
          type: string
          description: Human-readable tab label for display
          example: Created by Me
        total_count:
          type: integer
          description: Total number of tickets in this tab (for badge/View All)
          example: 24
        tickets:
          type: array
          description: Preview tickets (at most per_tab items)
          items:
            "$ref": "#/components/schemas/SupportTicketSummary"
    AbsenceReason:
      type: object
      description: |
        One tenant-configured absence reason code, as offered by
        `GET /absence_reasons`. Post `id` back as
        `absence_report[absence_reason_code_id]` when filing the report.
      required:
      - id
      - code
      - name
      - label
      - active
      properties:
        id:
          type: integer
        code:
          type: string
          description: 'Stable per-tenant identifier (e.g. `sick`, `running_late`).
            Renaming the reason changes `name`/`label`, never this.

            '
        name:
          type: string
          description: The tenant's own label — the exact string the web picker shows.
        label:
          type: string
          description: Alias of `name`. Both carry the same tenant string.
        description:
          type: string
          nullable: true
        category:
          type: string
          enum:
          - general
          - illness
          - personal
          - transportation
          - family
          - other
        category_label:
          type: string
          nullable: true
          description: Display form of `category` (e.g. `illness` -> "Illness/Medical").
        requires_documentation:
          type: boolean
          description: |
            ADVISORY ONLY. Nothing enforces it — no absence surface has an
            attachment field — so render it as guidance ("documentation may be
            required"). Do not gate submission on it, and do not promise an
            upload step. The web form deliberately stopped appending
            "(Requires Documentation)" to the option text for this reason.
        documentation_threshold_days:
          type: integer
          description: Absence length past which the tenant expects documentation.
            0 = none.
        requires_manager_approval:
          type: boolean
        advance_notice_required_days:
          type: integer
          description: Notice the tenant's policy expects for this reason. 0 = none.
        requires_estimated_arrival:
          type: boolean
          description: 'Collect an estimated arrival time for this reason — it is
            a DELAY, not an absence. True for the code the server treats as "running
            late", which is also what decides whether the ETA is shown to the manager,
            so key the field on this rather than on the label.

            '
        is_paid:
          type: boolean
        active:
          type: boolean
          description: 'False only in an `include_inactive=true` response — a retired
            code, kept so an existing report can still be named. Never offer one in
            a new-report picker.

            '
    UndoClockOutWindow:
      type: object
      description: |
        Server-truth state of the Undo Clock-Out window, as carried by
        `POST /attendance_records/{id}/check_out`,
        `POST /attendance_records/{id}/undo_clock_out` and
        `GET /attendance_records/status`. The same four keys are always present;
        when the undo is unavailable, `can_undo_clock_out` is false,
        `undo_deadline` is null and `undo_seconds_remaining` is 0.
      required:
      - attendance_record_id
      - can_undo_clock_out
      - undo_deadline
      - undo_seconds_remaining
      properties:
        attendance_record_id:
          type: integer
          nullable: true
          description: The record to POST the undo to, or null when there is nothing
            to undo.
        can_undo_clock_out:
          type: boolean
          description: Whether the caller may undo this clock-out right now.
        undo_deadline:
          type: string
          format: date-time
          nullable: true
          description: ISO-8601 instant the window closes (clock-out time + 15 minutes).
        undo_seconds_remaining:
          type: integer
          description: |
            Whole seconds left, measured on the server clock. Render the
            countdown from this rather than from `undo_deadline` so a device
            with clock drift still counts down correctly.
    SocialPostEnvelope:
      type: object
      properties:
        post:
          type: object
          properties:
            urn:
              type: string
              example: urn:li:share:1234567890
            status:
              type: string
              enum:
              - pending
              - published
              - scheduled
              - failed
            author:
              type: string
              enum:
              - organization
              - member
            provider:
              type: string
              enum:
              - linkedin
            published_at:
              type: string
              format: date-time
              nullable: true
            error_code:
              type: string
              nullable: true
              description: |
                Internal error vocabulary — set when status=failed.
                One of: not_connected, auth_expired, invalid_payload,
                rate_limited, provider_error, unexpected.
            error_message:
              type: string
              nullable: true
            created_at:
              type: string
              format: date-time
    ErrorEnvelope:
      type: object
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
            message:
              type: string
            details:
              type: object
              additionalProperties: true
    SurveySummary:
      type: object
      description: A survey as it appears in a list. The serializer is an explicit
        allowlist — the record is never rendered wholesale, so no internal column
        and no respondent identity can leak into a response.
      required:
      - id
      - name
      - survey_type
      - status
      - is_open
      - is_anonymous
      - questions_count
      properties:
        id:
          type: integer
        name:
          type: string
        survey_type:
          type: string
          enum:
          - engagement
          - pulse
          - custom
          - manager_feedback
        status:
          type: string
          enum:
          - draft
          - active
          - closed
          - archived
        is_open:
          type: boolean
          description: 'True when the survey is `active` AND the current time is inside
            its `opens_at`/`closes_at` window. Not a restatement of `status` — an
            `active` survey outside its window reports `is_open: false`.'
        is_anonymous:
          type: boolean
          description: When true, responses are stored with identity hidden on every
            display surface, and results below the tenant's anonymity floor are suppressed
            entirely.
        description:
          type: string
          nullable: true
        opens_at:
          type: string
          format: date-time
          nullable: true
        closes_at:
          type: string
          format: date-time
          nullable: true
        questions_count:
          type: integer
          description: Caller-fillable input questions only; display blocks (instruction
            / section / header) are excluded. `0` for a survey with no form template.
        response_count:
          type: integer
          nullable: true
          description: PRESENT ONLY for a caller who may see it, and it means different
            things by tier. In the `scope=created` list, and on the detail payload
            at the `:all` / `:aggregate` results tiers, it is the whole-survey count
            of submitted-or-approved responses. On the detail payload at the `:team`
            tier it counts the caller's own direct reports only, and is `null` when
            an anonymous survey's team count is below the tenant's anonymity floor.
            Absent entirely from the employee self-service list and for a caller at
            the `:none` tier.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SurveyDetail:
      allOf:
      - "$ref": "#/components/schemas/SurveySummary"
      - type: object
        description: The single-survey payload — everything above plus the questions.
        required:
        - completed
        - questions
        properties:
          completed:
            type: boolean
            description: Whether this caller has already submitted a response. A question
              of FACT, not of policy — a survey whose template allows multiple submissions
              still accepts another response while this is true.
          questions:
            type: array
            description: The caller-fillable questions, in builder order.
            items:
              "$ref": "#/components/schemas/SurveyQuestion"
          target_audience_count:
            type: integer
            description: Whole-survey audience size. Present only at the `:all` /
              `:aggregate` results tiers — never for a `:team` manager, and never
              for a caller at the `:none` tier.
          completion_rate:
            type: number
            format: float
            description: Whole-survey completion as a PERCENTAGE (0–100, one decimal
              place), not a fraction. Same tier rule as `target_audience_count`.
            example: 42.9
    SurveyQuestion:
      type: object
      description: One caller-fillable question.
      required:
      - id
      - field_name
      - label
      - field_type
      - required
      properties:
        id:
          type: integer
        field_name:
          type: string
          description: The key to use for this question inside `submission_data`.
        label:
          type: string
        field_type:
          type: string
          description: The underlying form field type — e.g. `text`, `textarea`, `number`,
            `email`, `select`, `multiselect`, `checkbox`, `radio`, `date`, `rating`,
            `slider`, `scale`, `matrix`, `rich_text`, `signature`, `file`, `image`.
            Determines the answer shape accepted by `respond` and whether the question
            is rolled up in `results`.
        required:
          type: boolean
        help_text:
          type: string
          nullable: true
          description: Null when the question carries no help text.
        options:
          type: array
          description: The selectable option values, for choice-style questions. An
            empty array for every other field type. Option objects configured with
            separate value/label pairs are flattened to their value.
          items:
            type: string
    SurveySubmission:
      type: object
      description: The receipt returned when a response is accepted.
      required:
      - id
      - survey_id
      - status
      - is_anonymous
      properties:
        id:
          type: integer
        survey_id:
          type: integer
        status:
          type: string
          description: Always `submitted` on this path — this endpoint writes that
            status explicitly and nothing downstream reassigns it.
          example: submitted
        is_anonymous:
          type: boolean
          description: The SURVEY's anonymity setting, echoed back on the receipt.
        submitted_at:
          type: string
          format: date-time
        message:
          type: string
          description: A ready-to-display confirmation naming the survey.
          example: Thank you! Your response to 'Q3 Engagement Pulse' has been submitted.
    SurveyResults:
      type: object
      description: Aggregate results for the caller's tier. Raw per-respondent rows
        and respondent identity never appear here.
      required:
      - survey
      - scope
      - minimum_response_threshold
      - threshold_met
      - question_summaries
      properties:
        survey:
          "$ref": "#/components/schemas/SurveyResultsSurvey"
        scope:
          type: string
          enum:
          - full
          - team
          description: "`full` for the `:all` and `:aggregate` tiers (org-wide numbers);
            `team` for a manager reading a slice narrowed to their own direct reports."
        minimum_response_threshold:
          type: integer
          minimum: 5
          description: The tenant's anonymity floor, after the platform minimum of
            5 is applied. A tenant may raise this but not lower it.
        threshold_met:
          type: boolean
          description: Whether the response count for this scope is positive AND at
            or above the threshold. Always `false` in the suppressed branch.
        anonymity_protected:
          type: boolean
          description: PRESENT ONLY when suppression fired — an anonymous survey whose
            response count for this scope is below the threshold. When present it
            is `true`, `question_summaries` is empty, and no aggregate of any kind
            is returned. Absent (not `false`) on an unsuppressed response.
          example: true
        question_summaries:
          type: array
          description: One entry per rolled-up question, in builder order. ONLY these
            field types are rolled up — `rating`, `number`, `slider`, `scale` (numeric);
            `select`, `radio`, `checkbox`, `multiselect` (choice); `text`, `textarea`
            (text). Every other question type is omitted from this array entirely.
            Empty in the suppressed branch.
          items:
            "$ref": "#/components/schemas/SurveyQuestionSummary"
    SurveyResultsSurvey:
      type: object
      description: The survey header block inside a results payload.
      required:
      - id
      - name
      - is_anonymous
      - status
      properties:
        id:
          type: integer
        name:
          type: string
        is_anonymous:
          type: boolean
        status:
          type: string
          enum:
          - draft
          - active
          - closed
          - archived
        response_count:
          type: integer
          nullable: true
          description: Responses counted for this scope — org-wide for `full`, the
            caller's direct reports for `team`. `null` when a `team`-scoped count
            on an anonymous survey falls below the anonymity floor.
        target_audience_count:
          type: integer
          description: "`full` scope only — a team slice has no audience denominator."
        completion_rate:
          type: number
          format: float
          description: "`full` scope only. A PERCENTAGE (0–100, one decimal place),
            computed from the same `response_count` above; `0.0` when the audience
            is empty."
    SurveyQuestionSummary:
      type: object
      description: One question's roll-up. `type` selects which of the remaining fields
        are present, and any of the three can instead carry `insufficient_data`.
      required:
      - field_name
      - label
      - type
      - count
      properties:
        field_name:
          type: string
        label:
          type: string
        type:
          type: string
          enum:
          - numeric
          - choice
          - text
        count:
          type: integer
          description: For `numeric`, the number of answers that parsed as a number.
            For `choice`, the number of respondents who chose at least one option.
            For `text`, the number of non-blank answers.
        insufficient_data:
          type: boolean
          description: "`numeric` and `choice` only, and present only when `count`
            is below `minimum_response_threshold`. When present, `average`/`min`/`max`
            and `distribution` are all absent. `text` summaries are never marked this
            way — they carry a count and nothing else at any volume."
          example: true
        average:
          type: number
          format: float
          description: "`numeric` only, rounded to 2 decimal places."
        min:
          type: number
          format: float
          description: "`numeric` only."
        max:
          type: number
          format: float
          description: "`numeric` only."
        distribution:
          type: object
          description: "`choice` only — keyed by the stored answer value. `percentage`
            is that option's share of TOTAL SELECTIONS, not of respondents, so on
            a multi-select question the percentages are over the number of boxes ticked
            rather than the number of people; `0.0` when nothing was selected."
          additionalProperties:
            type: object
            properties:
              count:
                type: integer
              percentage:
                type: number
                format: float
    SurveyAnonymousFeedback:
      type: object
      description: A recorded anonymous feedback item. No submitter identity is stored
        on it.
      required:
      - id
      - topic
      - topic_label
      - message
      - status
      properties:
        id:
          type: integer
        topic:
          type: string
          enum:
          - workload
          - safety
          - leadership
          - culture
          - compensation
          - communication
          - other
        topic_label:
          type: string
          description: The display label for `topic`, from the app's single label
            taxonomy (e.g. `workload` renders as "Workload & Work-Life Balance").
            An unmapped topic falls back to a titleized form.
          example: Workload & Work-Life Balance
        message:
          type: string
        status:
          type: string
          enum:
          - new
          - in_progress
          - closed
          description: Always `new` on creation.
        claim_code:
          type: string
          nullable: true
          description: The one-way code the submitter keeps in order to look up this
            item's status later. Never tied to identity. 8 characters from an unambiguous
            uppercase alphabet (0/O/1/I/L excluded so a hand-copied code cannot be
            misread), unique per business.
          example: K7QM3XPB
        created_at:
          type: string
          format: date-time
    SurveysPaginationMeta:
      type: object
      description: Pagination metadata, computed off the live relation rather than
        off the returned page. The same four values are also returned as the `X-Total-Count`,
        `X-Total-Pages`, `X-Current-Page` and `X-Per-Page` response headers.
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    UnreadNotificationCountRef:
      type: integer
      description: Platform piggyback field — the calling user's unread, active notification
        count in the current business, used for the native app badge. Present only
        on the endpoint that renders through the piggyback path (`GET /surveys/surveys`),
        only when both a user and a business resolved, and degrades to `0` rather
        than erroring.
      example: 3
    PlatformHttpMeta:
      type: object
      description: Platform HTTP-enhancement block added by the piggyback renderer.
        Present on `GET /surveys/surveys` only. Its `http.caching.etag` is what a
        client echoes in `If-None-Match` to get a `304`.
      properties:
        http:
          type: object
          properties:
            caching:
              type: object
            navigation:
              type: object
            performance:
              type: object
            client_hints:
              type: object
    TrainingQuizEnrollmentState:
      type: object
      nullable: true
      description: |-
        The caller's standing in the COURSE the quiz belongs to. Every quiz endpoint requires an enrollment, so this is null only in the degenerate case where one has been removed between requests.

        The SCORM card endpoints report the same block from the same fields, and reference this schema rather than restating it.
      properties:
        id:
          type: integer
          example: 5512
        status:
          type: string
          enum:
          - enrolled
          - in_progress
          - completed
          - cancelled
          description: "**This is the field that routes a client.** Show a course-completion
            screen for `completed` and nothing else. Note a learner can sit at `progress_percentage:
            100` and still be `in_progress` — passing a required quiz is a separate
            condition from finishing the lessons, so do not treat 100% as completion."
        progress_percentage:
          type: integer
          example: 83
        completed_at:
          type: string
          format: date-time
          nullable: true
    TrainingQuizCourseState:
      type: object
      description: Where the learner now stands in the course — returned by submit
        and results, because passing an assessment can complete the course and a client
        needs to know that without a second request.
      properties:
        enrollment:
          "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
        certificate:
          type: object
          description: A STATE, not just an id, because issuing a certificate is asynchronous.
            A client that has just submitted a passing final attempt will legitimately
            see `pending`; poll `GET /training/my_records/certificates` rather than
            reading a missing id as "this course has no certificate".
          properties:
            status:
              type: string
              enum:
              - issued
              - pending
              - not_applicable
              description: "`not_applicable` while the enrollment is not yet complete;
                `pending` once it is but the certificate job has not finished; `issued`
                when `id` is present."
            id:
              type: integer
              nullable: true
              example: 8801
    TrainingQuizCard:
      type: object
      description: The assessment entry point — see `GET .../lessons/{lesson_id}/quiz`.
      properties:
        lesson_id:
          type: integer
          nullable: true
          example: 412
        lesson_title:
          type: string
          nullable: true
          example: Safety Assessment
        quiz:
          type: object
          properties:
            id:
              type: integer
              example: 77
            title:
              type: string
              example: Safety Assessment
            description:
              type: string
              nullable: true
            instructions:
              type: string
              nullable: true
              description: Authored pre-flight copy ("you may not pause this", "have
                your SDS sheet to hand"). RENDER IT before the learner starts — it
                is instructions they were meant to read first, not decoration.
            question_count:
              type: integer
              example: 7
            total_points:
              type: integer
              example: 12
            passing_score:
              type: integer
              description: Percent needed to pass.
              example: 80
            time_limit_minutes:
              type: integer
              nullable: true
              description: Null on an untimed quiz — draw no timer at all rather than
                a zero.
              example: 15
            question_display:
              type: string
              enum:
              - one_per_page
              - all_at_once
              description: 'How the quiz was authored to be taken: a stepper, or every
                question on one page. Both post the identical `answers` payload.'
            shuffled:
              type: boolean
              description: Whether question ORDER is shuffled per learner.
        attempts:
          type: object
          properties:
            taken:
              type: integer
              description: TERMINAL attempts used (submitted or timed out). An attempt
                still open is the learner's current one, not a used one.
              example: 1
            max:
              type: integer
              nullable: true
              description: Null when unlimited.
              example: 3
            remaining:
              type: integer
              nullable: true
              description: "**Null, not 0, when there is no attempt limit** — an unlimited
                pool has no remainder, and 0 would read as exhausted."
              example: 2
            can_attempt:
              type: boolean
              example: true
            block_reason:
              type: string
              nullable: true
              description: 'Null when nothing blocks. Otherwise a learner-facing sentence
                naming which of the three gates stopped them: the pool is used up,
                they have already passed and the quiz only allows retakes after a
                fail, or a cooldown is running. Display it verbatim.'
              example: You've used all your attempts for this quiz.
            next_available_at:
              type: string
              format: date-time
              nullable: true
              description: When a cooldown lifts, so a client can count down instead
                of polling to discover the gate has gone. Null when no cooldown is
                running.
        passed:
          type: boolean
          description: Whether ANY attempt in THIS enrollment has passed — scoped
            to the enrollment, never the learner's lifetime, so a re-assigned course
            is not pre-passed by last year's attempt.
        outcome:
          type: object
          nullable: true
          description: The learner's standing result, or null before anything is graded.
            This is the attempt their Pass/Fail VERDICT came from — **not** necessarily
            the most recent one. On a `highest` score policy a failing retake leaves
            the verdict here on the earlier pass, so do not infer it from the newest
            attempt.
          properties:
            attempt_id:
              type: integer
              example: 9014
            attempt_number:
              type: integer
              example: 1
            score_percentage:
              type: integer
              nullable: true
              example: 86
            passed:
              type: boolean
            timed_out:
              type: boolean
            submitted_at:
              type: string
              format: date-time
              nullable: true
            effective_score:
              type: integer
              nullable: true
              description: The score that COUNTS toward completion and the transcript,
                under the quiz's score policy (highest / latest / average / first).
                Can differ from `score_percentage` above.
              example: 86
        open_attempt:
          type: object
          nullable: true
          description: |-
            An attempt still in progress, or null. Its clock has been running since `started_at` and has NOT paused.

            An attempt can be still-open and already out of time, in which case it cannot be resumed — see `expired`. You no longer have to special-case that yourself: `cta.action` reports `view_results` for it, so following the CTA is correct on its own. `expired` is here so you can say WHY ("your previous attempt ran out of time") rather than to decide what the button does.
          properties:
            id:
              type: integer
              example: 9014
            attempt_number:
              type: integer
              example: 2
            started_at:
              type: string
              format: date-time
              nullable: true
            current_position:
              type: integer
              nullable: true
              example: 3
            answered_count:
              type: integer
              example: 3
            time_remaining_seconds:
              type: integer
              nullable: true
              example: 742
            expired:
              type: boolean
              description: |-
                True when this attempt is still `in_progress` but its clock has run out — the learner walked away and the timer did not pause. **It is NOT resumable.** `POST .../quiz/attempts` will grade it from its saved answers and answer 409 `attempt_timed_out` with its id rather than returning a playable quiz, so label the control "View result" and go straight to that attempt's results.

                The card is a read and deliberately does not finalize the attempt itself, which is why it reports the fact instead. Always false on an untimed quiz.
              example: false
        cta:
          type: object
          description: The ONE primary action, resolved server-side so every client
            offers the same thing. See the endpoint description for the table of actions.
          properties:
            action:
              type: string
              enum:
              - start
              - resume
              - retake
              - view_results
              - locked
            attempt_id:
              type: integer
              nullable: true
              description: The attempt to resume, retake from, or show results for.
            reason:
              type: string
              nullable: true
              description: Present on `locked` and `view_results` — why no further
                attempt is allowed.
    TrainingQuizQuestion:
      type: object
      description: |-
        ONE question as the LIVE PLAYER sees it. **Contains no correct answer, in any form** — read the answer-stripping contract at the top of this section, then switch on `question_type` and read the ONE type-specific block that applies:

        `multiple_choice` / `multiple_select` → `options`

        `true_false` → nothing (render the two)

        `text` → `text_input`

        `ranking` → `items` (already shuffled; render in the order given)

        `matching_text` / `matching_image` → `pairs` + `choices` (tokens)

        `hotspot` → `hotspot` (no regions)
      properties:
        id:
          type: integer
          description: The SEALED EDITION's question id. **Key your `answers` object
            by this** — not by an id from the course detail or a previous attempt.
          example: 30455
        position:
          type: integer
          description: 1-based
          for "Q3 of 7". example: 3
        question_type:
          type: string
          enum:
          - multiple_choice
          - true_false
          - multiple_select
          - text
          - ranking
          - matching_text
          - matching_image
          - hotspot
        type_label:
          type: string
          description: Learner-facing name of the type, e.g. `Multiple choice`.
        question_text:
          type: string
        points:
          type: integer
          example: 2
        mandatory:
          type: boolean
          description: Must be answered before the attempt can be submitted. The server
            enforces this too (422 `unanswered_mandatory`), so a client gate is a
            courtesy, not the rule.
        auto_graded:
          type: boolean
          description: Whether the SERVER grades it. False only for `text`, which
            a human reviews — surface that ("reviewed by your instructor") rather
            than implying an instant score.
        answered:
          type: boolean
          description: Whether `answer` counts as a real answer, judged per type (a
            `true_false` of `false` counts; a `matching_*` needs one filled pair).
            The same check the mandatory gate on submit uses.
        answer:
          nullable: true
          description: 'What the learner has already entered, **in the same form you
            post it**, so a resumed attempt rehydrates. Per type: an option id string
            (`multiple_choice`), `"true"`/`"false"`, an array of option ids (`multiple_select`),
            a string (`text`), an ordered array of item ids (`ranking`), `{ "<pair
            id>": "<token>" }` (`matching_*` — TOKENS, not the ids stored internally),
            or an array of `{x, y}` percent points (`hotspot`). Null before anything
            is entered.'
        options:
          type: array
          description: "`multiple_choice` and `multiple_select` only. Nothing marks
            the correct one."
          items:
            type: object
            properties:
              id:
                type: string
                example: o2
              text:
                type: string
        text_input:
          type: object
          description: "`text` only — how to draw the box and what length to accept."
          properties:
            min_length:
              type: integer
              nullable: true
              example: 20
            max_length:
              type: integer
              nullable: true
              example: 500
            mode:
              type: string
              nullable: true
              enum:
              - short
              - long
              description: Null for a question authored before the setting existed
                — pick your own default.
        items:
          type: array
          description: "`ranking` only. **Already in the order to display**, and that
            order is a derangement of the authored (correct) one — no item sits in
            its correct slot. It is stable for the attempt, and becomes the learner's
            own arrangement once they have made one. Never sort it. Post the ids back
            in the order the learner leaves them."
          items:
            type: object
            properties:
              id:
                type: string
                example: s3
              text:
                type: string
        pairs:
          type: array
          description: "`matching_text` / `matching_image` only — the LEFT column,
            the fixed prompts, in authored order. `image_url` is present for `matching_image`
            (a 250x150 crop, so every tile is the same shape) and absent otherwise."
          items:
            type: object
            properties:
              id:
                type: string
                example: l2
              left:
                type: string
              image_url:
                type: string
                nullable: true
        choices:
          type: array
          description: '`matching_text` / `matching_image` only — the RIGHT column,
            shuffled, and addressed **only** by opaque `token`. There is deliberately
            no id here to correlate with `pairs[].id`, because that correlation is
            the answer. Post `{ "<pairs[].id>": "<choices[].token>" }`; the server
            maps the token back. Tokens are stable for the question, so an autosaved
            answer round-trips.'
          items:
            type: object
            properties:
              token:
                type: string
                example: 4f2c91ab77e30d15
              text:
                type: string
        hotspot:
          type: object
          description: "`hotspot` only. **The correct regions are absent entirely**
            — not shuffled, not partial. Post the learner's taps as an array of `{x,
            y}` percentages of the image (0-100, origin top-left) and the server decides
            correctness by point-in-region."
          properties:
            image_url:
              type: string
              nullable: true
              description: The background to tap on. Null if the admin never uploaded
                one.
            max_markers:
              type: integer
              description: How many markers the learner may place (the number of regions
                authored).
              example: 1
            rule:
              type: string
              enum:
              - any_one
              - all
              description: 'What the count MEANS, and it changes the task: `any_one`
                needs one region hit, `all` needs every region covered. Say which
                in the prompt — otherwise the server can fail a learner over a distinction
                the screen never mentioned.'
    TrainingQuizAttempt:
      type: object
      description: A LIVE attempt and everything needed to render the player.
      properties:
        id:
          type: integer
          example: 9014
        attempt_number:
          type: integer
          example: 2
        state:
          type: string
          enum:
          - in_progress
          - submitted
          - timed_out
          description: Always `in_progress` from the player endpoint, which refuses
            the other two.
        started_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: The absolute instant the clock runs out. **Count down against
            this**, not by decrementing `time_remaining_seconds` locally — a device
            that slept will drift, and the server does not accept a late submission
            on trust. Null on an untimed quiz.
        time_remaining_seconds:
          type: integer
          nullable: true
          description: Seconds left as of this response; what to show immediately.
            Null on an untimed quiz.
          example: 742
        current_position:
          type: integer
          nullable: true
          description: The question the learner was last on, written by autosave —
            so a resume reopens there rather than back at question 1.
          example: 3
        question_count:
          type: integer
          example: 7
        answered_count:
          type: integer
          example: 3
        quiz:
          type: object
          description: The EFFECTIVE settings — frozen with this attempt's edition,
            not read live. An admin changing the passing score or time limit mid-attempt
            cannot move this learner's bar or clock, so these are the figures to display.
          properties:
            id:
              type: integer
              example: 77
            title:
              type: string
              example: Safety Assessment
            passing_score:
              type: integer
              example: 80
            time_limit_minutes:
              type: integer
              nullable: true
              example: 15
            question_display:
              type: string
              enum:
              - one_per_page
              - all_at_once
            total_points:
              type: integer
              example: 12
            attempt_number:
              type: integer
              example: 2
            max_attempts:
              type: integer
              nullable: true
              example: 3
        questions:
          type: array
          description: Every question, in the order frozen onto this attempt when
            it started. Render them in this order; it does not change across re-reads.
          items:
            "$ref": "#/components/schemas/TrainingQuizQuestion"
    TrainingQuizAnswers:
      type: object
      description: |-
        The learner's answers, keyed by the question ids **this attempt's player returned** (the sealed edition's ids — see the section header). Ids from anywhere else will be reported as unanswered.

        One value shape per question type:

        `multiple_choice` → the option id, as a string (`"o2"`)

        `true_false` → `"true"` or `"false"`

        `multiple_select` → array of option ids (`["h1","h2"]`)

        `text` → the answer string

        `ranking` → array of item ids in the learner's order (`["s2","s1","s4","s3"]`)

        `matching_text` / `matching_image` → `{ "<pair id>": "<choice token>" }`

        `hotspot` → array of `{x, y}` percent points. May also be sent as a JSON STRING of that array, which is what the web player posts; both are accepted.

        Send the whole object on every autosave — it REPLACES what was stored, it is not merged.
      additionalProperties: true
      example:
        '30455': o2
        '30456':
        - h1
        - h2
        '30457': Because stored energy can start the machine unexpectedly.
        '30458':
        - s2
        - s1
        - s4
        - s3
        '30459':
          l1: 4f2c91ab77e30d15
          l2: 9b70ee2c1d448a03
        '30460':
        - x: 42.5
          "y": 63.1
    TrainingQuizAnswerReview:
      type: object
      description: One row of the post-submit review. The only place `correct_answer`
        and `explanation` are ever emitted.
      properties:
        question_id:
          type: integer
          example: 30455
        position:
          type: integer
          example: 1
        question_type:
          type: string
          enum:
          - multiple_choice
          - true_false
          - multiple_select
          - text
          - ranking
          - matching_text
          - matching_image
          - hotspot
        type_label:
          type: string
          example: Multiple choice
        question_text:
          type: string
        points:
          type: integer
          example: 1
        points_earned:
          type: integer
          example: 1
        status:
          type: string
          enum:
          - correct
          - incorrect
          - pending_review
          description: "**`pending_review` is not a failure.** A `text` answer is
            graded by a human and is excluded from both sides of the score, so render
            it as awaiting review — showing it as incorrect tells the learner they
            failed a question nobody has read yet."
        your_answer:
          type: string
          description: The learner's answer, already rendered for display per type
            — `"A → B → C"` for a ranking, `"3 of 4 pairs correct"` for a matching,
            `"2 areas marked"` for a hotspot, and the literal `"No answer"` when they
            left it blank (so a row never shows an empty space the learner has to
            interpret).
        correct_answer:
          type: string
          nullable: true
          description: The expected answer, rendered per type. **Null for `text`**
            — there is no single correct answer to show.
        explanation:
          type: string
          nullable: true
          description: The author's note on why, when one was written.
    TrainingQuizResult:
      type: object
      description: A GRADED attempt — the post-submit results screen.
      properties:
        attempt:
          type: object
          properties:
            id:
              type: integer
              example: 9014
            attempt_number:
              type: integer
              example: 1
            state:
              type: string
              enum:
              - submitted
              - timed_out
            timed_out:
              type: boolean
              description: 'A persisted OUTCOME, not a clock reading: the attempt
                was finalized because time ran out, and was graded from the last autosave.
                Say "time''s up" rather than presenting it as a normal submission.'
            submitted_at:
              type: string
              format: date-time
              nullable: true
            time_taken_seconds:
              type: integer
              nullable: true
              example: 421
            formatted_time_taken:
              type: string
              example: 7m 1s
        score:
          type: object
          description: '**The three counts are separate on purpose.** Written (`text`)
            answers are human-graded and excluded from both the numerator and the
            denominator, so compose any "N of M correct" sentence from `correct_count`
            / `auto_graded_count` and report `pending_review_count` alongside. A single
            fused figure would count a question nobody has graded and contradict the
            review list below it.'
          properties:
            score_percentage:
              type: integer
              nullable: true
              example: 86
            passed:
              type: boolean
            points_earned:
              type: integer
              example: 10
            total_points:
              type: integer
              description: Auto-gradable points on offer — the denominator of `points_earned`.
              example: 12
            passing_score:
              type: integer
              description: The bar this attempt was actually graded against, frozen
                with its edition — print this even if the quiz has since moved.
              example: 80
            correct_count:
              type: integer
              example: 6
            incorrect_count:
              type: integer
              example: 0
            auto_graded_count:
              type: integer
              description: The denominator of correct/incorrect — auto-gradable questions
                only.
              example: 6
            pending_review_count:
              type: integer
              description: Written answers a human has still to read. Non-zero means
                the score is provisional for those rows.
              example: 1
            effective_score:
              type: integer
              nullable: true
              description: The score that COUNTS toward completion and the transcript,
                per the quiz's score policy. On a `highest` quiz a failing retake
                leaves this at the earlier pass, so do not infer it from `score_percentage`.
              example: 86
        review:
          type: array
          nullable: true
          description: Per-question outcome, or **null** when the attempt's edition
            had answer reveal off. Null is not an error and not an empty result —
            render `review_hidden_reason` instead of an empty list.
          items:
            "$ref": "#/components/schemas/TrainingQuizAnswerReview"
        review_hidden_reason:
          type: string
          nullable: true
          enum:
          - answers_not_revealed
          description: Null when `review` is present.
        attempts:
          type: object
          description: The attempt pool, and every graded attempt for the "your attempts"
            switcher.
          properties:
            taken:
              type: integer
              example: 2
            max:
              type: integer
              nullable: true
              example: 3
            remaining:
              type: integer
              nullable: true
              example: 1
            graded:
              type: array
              description: Newest first. Every graded attempt is listed, including
                ones the standing verdict did NOT come from — without this a learner
                could only reach them by guessing an id. `current` flags the one being
                shown.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  attempt_number:
                    type: integer
                  score_percentage:
                    type: integer
                    nullable: true
                  passed:
                    type: boolean
                  timed_out:
                    type: boolean
                  submitted_at:
                    type: string
                    format: date-time
                    nullable: true
                  current:
                    type: boolean
        retake:
          type: object
          description: Whether to offer a Retake at all. Read from the same gates
            as the card, so the two surfaces cannot disagree.
          properties:
            allowed:
              type: boolean
            reason:
              type: string
              nullable: true
              description: Learner-facing sentence when not allowed; display verbatim.
            next_available_at:
              type: string
              format: date-time
              nullable: true
    TrainingOfflineLesson:
      type: object
      description: One lesson packaged for storage on the device. Everything here
        is either the body itself or something the client needs to make that body
        work with no server.
      properties:
        content_type:
          type: string
          enum:
          - text
          - video
          - document
          - quiz
          - scorm
          - partner_course
          example: text
        offline_supported:
          type: boolean
          description: Whether this lesson can be stored and rendered with no connection.
            COMPUTED HERE ON PURPOSE — a client could derive it from `content_type`
            plus the video provider, but then every platform re-implements the rule
            and they drift. Same argument as `can_register` on the sessions endpoints.
          example: true
        offline_blocked_by:
          type: string
          nullable: true
          enum:
          - online_only_runtime
          - external_video
          description: WHY it cannot go offline, so the client can say so rather than
            silently omitting the lesson. `online_only_runtime` — a `scorm` or `partner_course`
            lesson, whose runtime is not ours to ship, or a TIMED `quiz` (an untimed
            quiz IS offline-capable — see `quiz`). `external_video` — a YouTube/Vimeo
            embed. Null when `offline_supported` is true.
          example:
        description:
          type: string
          nullable: true
          description: The author's lesson description, sanitised like `html`, for
            EVERY lesson type — the line the online lesson page prints under its eyebrow.
            `html` folds it in only for non-text lessons, so a stored text lesson
            reads it from here. Null when the author left it blank.
          example: Configure your physical locations
        quiz:
          type: object
          nullable: true
          description: 'For an UNTIMED quiz lesson only: the quiz''s sealed edition,
            answer-stripped exactly as the live player is (no correct answers, matching
            addressed by opaque tokens, hotspot regions withheld), so the assessment
            can be taken with no connection and replayed through `POST /training/quiz_attempts/offline`.
            Key that replay''s `answers` by these question ids. Null for every other
            lesson and for a timed quiz.'
          properties:
            id:
              type: integer
            version_id:
              type: integer
              description: The sealed edition the ids belong to.
            title:
              type: string
            instructions:
              type: string
              nullable: true
            passing_score:
              type: integer
            question_display:
              type: string
              enum:
              - one_per_page
              - all_at_once
            question_count:
              type: integer
            max_attempts:
              type: integer
              nullable: true
            questions:
              type: array
              items:
                "$ref": "#/components/schemas/TrainingQuizQuestion"
        html:
          type: string
          nullable: true
          description: |-
            The lesson body, SANITISED through the same allowlist the web renders with (`training_rich_text`), so offline and online show byte-identical markup and the allowlist has one definition. Never the raw column: raw author markup can carry a `<script>`, an `onerror=` or a `javascript:` href, and an offline copy re-executes it every time the lesson opens — including when nothing can reach the device to revoke it.

            For a TEXT lesson this is the authored body. For every other type it is the lesson's description, which the client renders above whatever native surface it builds for the media. Null when there is neither.
          example: "<p>Before servicing any powered equipment…</p>"
        stylesheets:
          type: array
          description: |-
            Absolute, digested URLs of the CSS this html needs — download each and rewrite the `<link>` hrefs. The SAME files the online webview links.

            TEXT LESSONS GET A THIRD ENTRY (`custom.css`) and the others do not: it is 438 KB and carries only the `.lesson-content` rules, which exist because the editor emits bullet AND numbered lists as `<ol>` — without them every bullet list an author typed renders as "1. 2. 3.". A client syncing video or document lessons should not pay for it.
          items:
            type: string
          example:
          - https://officechat.workforce.mangoapps.com/assets/mobile_tailwind-8f3a.css
          - https://officechat.workforce.mangoapps.com/vendor/fontawesome-pro-6.7.2-web/css/all.min.css
        document:
          type: object
          nullable: true
          description: |-
            The lesson's attached document, for a `document` lesson. A FIELD, not markup — it is a lesson-level attachment and was never part of the body, so parsing the html could not find it. Render it with the platform's own viewer.

            `url` IS SIGNED AND EXPIRING — download it, then point the stored copy at the local file. Null when nothing is attached.
          properties:
            url:
              type: string
              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/xyz/handbook.pdf
            filename:
              type: string
              example: handbook.pdf
            content_type:
              type: string
              example: application/pdf
            byte_size:
              type: integer
              example: 284913
        video:
          type: object
          nullable: true
          description: The lesson's video, for a `video` lesson. Read `downloadable`
            FIRST — the two cases have different fields and only one can go offline.
          properties:
            downloadable:
              type: boolean
              description: True for an UPLOADED video (url/filename/byte_size present).
                False for an embed, where only `provider` and `embed_url` are.
              example: true
            url:
              type: string
              nullable: true
              description: Signed and expiring
              same as `document.url`.:
            filename:
              type: string
              nullable: true
            content_type:
              type: string
              nullable: true
            byte_size:
              type: integer
              nullable: true
            duration_seconds:
              type: integer
              nullable: true
              example: 742
            provider:
              type: string
              nullable: true
              description: Present only when not downloadable.
              example: youtube
            embed_url:
              type: string
              nullable: true
              description: Present only when not downloadable — requires a connection.
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: When the lesson was last edited. Compare against what you stored
            to decide whether a re-sync needs to re-download this lesson at all.
          example: '2026-08-28T09:14:00Z'
    TrainingLessonProgressInput:
      type: object
      description: What a client reports about one lesson. EVERY FIELD IS OPTIONAL
        — send only what changed. An empty body is valid and just stamps the resume
        position.
      properties:
        completed:
          type: boolean
          description: "`true` completes the lesson. There is no `false` — completion
            is monotonic, so sending false does not un-complete (see the endpoint
            description). Safe to send repeatedly."
          example: true
        video_position:
          type: integer
          description: Seconds into the video. Stored as the MAX of this and what
            is already recorded, so an out-of-order flush cannot rewind the learner.
          example: 120
        time_spent:
          type: integer
          description: Seconds spent in THIS sitting — a delta, added to the stored
            total, not a total itself. Omit it and the server records wall-clock time
            from first open to completion instead (what the web records); send it
            and your figure wins, which is what an offline client wants, since a flush
            can arrive days after the lesson was finished.
          example: 300
    TrainingLessonProgress:
      type: object
      description: The lesson's progress AFTER the write, plus the two enrollment
        numbers a client would otherwise refetch to update the course row it came
        from.
      properties:
        lesson_id:
          type: integer
          example: 91
        completed:
          type: boolean
          example: true
        completed_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-09-01T12:35:49Z'
        video_position:
          type: integer
          nullable: true
          description: The stored value
          which may be HIGHER than what you just sent (max wins).:
          example: 120
        time_spent_seconds:
          type: integer
          nullable: true
          example: 300
        score:
          type: integer
          nullable: true
          description: Read-only — written by SCORM/xAPI callbacks and partner-content
            sync. A progress write never sets it; a learner does not grade themselves.
          example: 88
        course_progress_percentage:
          type: integer
          description: The enrollment's recalculated percentage — so a client can
            update the course card without a second call.
          example: 62
        course_status:
          type: string
          enum:
          - enrolled
          - in_progress
          - completed
          - cancelled
          example: in_progress
    TrainingSessionRegistrationSummary:
      type: object
      nullable: true
      description: |-
        The caller's hold for the course in WHATEVER state — including `attended` and `completed`, which the per-row `cta_action` deliberately ignores so a past attendance cannot make every other row offer "switch". Null when the caller has no live hold.

        **This block is self-contained on purpose.** It describes a session that may not be anywhere else in the response: the session list is `scheduled` + `upcoming`, so a seat the learner has already ATTENDED is in the past and appears in no list. It therefore carries the session's title, type and full time — never just `session_id`, which a client would have no row to resolve against.

        **Render `starts_at` in `timezone`.** The instant is given once, in UTC, plus the IANA zone of the VENUE — an instructor-led session happens in a specific place, so that zone is the one that matters, never the viewer's. Do NOT convert into the device's zone (a bare `new Date(starts_at)`) or you will show a learner in another country a time the session does not start at. `timezone_label` is the short form for display (`CDT`).

        `starts_at_local` / `ends_at_local` were emitted until 2026-09-01 and are gone — derivable from `starts_at` + `timezone` on every client platform.

        Identical shape from every endpoint that reports a hold: the course detail, the session list, a single session, and the register/cancel responses.
      properties:
        id:
          type: integer
        session_id:
          type: integer
        session_title:
          type: string
          nullable: true
          description: Title of the held session, so it renders without a list row.
        session_type:
          type: string
          nullable: true
          enum:
          - classroom
          - webinar
          - hybrid
        status:
          type: string
          enum:
          - registered
          - waitlisted
          - attended
          - completed
          - no_show
          - cancelled
        waitlisted:
          type: boolean
        waitlist_position:
          type: integer
          nullable: true
        switchable:
          type: boolean
          description: Whether this hold can still be moved or cancelled (registered
            or waitlisted only).
        awaiting_sign_off:
          type: boolean
          description: Attended, but still needs an instructor's sign-off.
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: UTC. For sorting and arithmetic, not for display.
        ends_at:
          type: string
          format: date-time
          nullable: true
        timezone:
          type: string
          nullable: true
          description: IANA name of the session's zone, e.g. `America/Chicago`.
        timezone_label:
          type: string
          nullable: true
          description: Short form for humans, e.g. `CDT`.
        duration_minutes:
          type: integer
          nullable: true
    TrainingScormCard:
      type: object
      description: The courseware launch panel — see `GET .../lessons/{lesson_id}/scorm`.
        The lesson type is labelled "SCORM" everywhere in the product, but the engine
        behind it plays SCORM 1.2, SCORM 2004, xAPI, cmi5 and AICC; `package.standard`
        is what says which one a given module is.
      properties:
        lesson_id:
          type: integer
          nullable: true
          example: 289
        lesson_title:
          type: string
          nullable: true
          example: Spill Response Simulation
        package:
          type: object
          description: 'The imported, playable form of the module. Null-ish throughout
            for a lesson whose package has never been imported — which is a real state,
            not a bug: see `launchable`.'
          properties:
            standard:
              type: string
              nullable: true
              enum:
              - scorm12
              - scorm2004
              - xapi
              - cmi5
              - aicc
            standard_label:
              type: string
              nullable: true
              description: How the standard is spelled for a learner — `SCORM`, `xAPI`,
                `cmi5`, `AICC`. Print this rather than deriving it from `standard`.
              example: SCORM
            title:
              type: string
              nullable: true
              description: The manifest's own title, which authors routinely write
                differently from the lesson title. Reported BESIDE `lesson_title`,
                not instead of it — the web cards show the lesson's.
            status:
              type: string
              nullable: true
              enum:
              - pending
              - processing
              - ready
              - failed
              description: The LESSON's import state. Diagnostic only — it is not
                the gate, and a tenant carrying lessons from before the native engine
                has them stamped `ready` with no package behind them.
            launchable:
              type: boolean
              description: "**The gate.** True when a launch can actually produce
                a player: the package imported and its files landed (or it is an AICC
                unit that runs on the provider's own server). This is the same check
                the launch itself applies, so render the primary control from here."
              example: true
            multi_unit:
              type: boolean
              description: A menu-driven package (several SCOs / AUs). The player
                owns the menu; the card reports the shape so it can say "6 of 18 sections".
            unit_count:
              type: integer
              nullable: true
              example: 1
            mastery_score:
              type: string
              nullable: true
              description: The bar the manifest sets, verbatim (a string in the manifest).
              example: '80'
            time_limit_seconds:
              type: integer
              nullable: true
              description: The manifest's max time allowed, parsed to seconds. The
                player enforces it; the card reports it so a learner knows before
                starting.
              example: 1200
            error:
              type: string
              nullable: true
              description: Why the import failed, as the importer recorded it.
              example: No imsmanifest.xml in the ZIP
        completion:
          type: object
          nullable: true
          description: 'The learner''s record for this lesson — the Status and Score
            tiles the web cards show. Null until the module is first launched: a card
            view is a read and creates nothing, so an untouched module never reads
            as started.'
          properties:
            completed:
              type: boolean
            completed_at:
              type: string
              format: date-time
              nullable: true
            started_at:
              type: string
              format: date-time
              nullable: true
            score:
              type: integer
              nullable: true
              description: |-
                0–100, projected from the runtime's own score. Null for a package that reports none, which many do — do not render 0 for it.

                **FROZEN once `completed` is true.** The commit that completes the lesson records its score and training time; nothing the runtime reports afterwards can change either. So this figure is final — safe to render as "your score" rather than "your latest score" — and a replay can neither improve nor damage it.
              example: 92
            time_spent_seconds:
              type: integer
              nullable: true
              description: Frozen at completion, like `score`. Before that it is the
                greater of the runtime's reported time and the wall clock since the
                lesson was started, so it never goes backwards.
              example: 900
        resume:
          type: object
          nullable: true
          description: Where the learner is INSIDE the module. Null when there is
            nothing to resume (never launched, or reset), so a fresh card stays fresh.
          properties:
            sentence:
              type: string
              nullable: true
              description: The one line the web cards print, composed server-side
                because the per-standard phrasing rule is the part that drifts — e.g.
                "Resume from Etiquette · 6 of 18 sections done · 42 min in".
              example: Resume where you left off · 7m 00s in
            percent:
              type: integer
              nullable: true
              description: 0–100, for the card's progress bar. Null when the package
                reports no measure.
              example: 37
            units_total:
              type: integer
              example: 18
            units_done:
              type: integer
              example: 6
            current_unit_title:
              type: string
              nullable: true
              example: Etiquette
            bookmarked:
              type: boolean
              description: The module reported a location or suspend data, so a relaunch
                resumes.
            finished:
              type: boolean
            time_spent:
              type: string
              nullable: true
              description: Humanised total, e.g. `42m 10s` / `1h 05m`. Null under
                a minute, where it reads as noise rather than progress.
            launches:
              type: integer
              description: How many times the module has been opened. **Not attempts**
                — courseware has no attempt pool; see the endpoint description.
              example: 2
            status:
              type: string
              enum:
              - not_attempted
              - incomplete
              - completed
              - passed
              - failed
              - browsed
              description: The runtime's own status for the registration.
        offline:
          type: object
          description: Always unsupported for courseware — the runtime is server-side,
            so there is nothing a client can store and play offline. Reported on the
            card because the launch control is where a learner decides whether to
            start something with no signal.
          properties:
            supported:
              type: boolean
              example: false
            blocked_by:
              type: string
              nullable: true
              example: online_only_runtime
        cta:
          type: object
          description: The one primary control, resolved server-side so three clients
            cannot each invent their own ladder.
          properties:
            action:
              type: string
              enum:
              - launch
              - resume
              - review
              - processing
              - unavailable
            label:
              type: string
              description: The verb the web cards print for this action.
              example: Launch module
            web_view_url:
              type: string
              nullable: true
              description: The chrome-less runtime to open in a WebView, absolute.
                **Null whenever there is nothing to open** — render the control only
                when it is present. Opening it STARTS the attempt (it mints the registration
                and a single-use session), so never prefetch it.
              example: https://acme.workforce.mangoapps.com/m/apps/training/courses/81/lessons/289/scorm?embed=1
            reason:
              type: string
              nullable: true
              enum:
              - package_processing
              - package_stalled
              - package_unavailable
              - enrollment_cancelled
              description: Why there is nothing to launch. `package_stalled` is an
                import that stopped part-way and will not resume on its own — it needs
                an admin to re-upload, which `message` says.
            message:
              type: string
              nullable: true
              description: Prose written for a learner. Show it verbatim rather than
                mapping the code.
              example: This module is still being prepared. Check back shortly.
            secondary:
              type: object
              nullable: true
              description: '"Start over" (`POST .../scorm/restart`) — discards the
                bookmark so the module plays from the beginning. Present ONLY on an
                unfinished module that has something to discard; null on a `review`
                card, because a completed lesson''s record is frozen and a reset there
                would promise a do-over that cannot be recorded.'
              properties:
                action:
                  type: string
                  enum:
                  - restart
                  example: restart
                label:
                  type: string
                  example: Start over
    WorkspacePaginationMeta:
      type: object
      description: Pagination state for the page just returned. The same values are
        also sent as the `X-Total-Count`, `X-Total-Pages`, `X-Current-Page` and `X-Per-Page`
        response headers.
      properties:
        total_count:
          type: integer
          description: Rows matching the request, across all pages.
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
          description: The page size actually used, after clamping.
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    WorkspaceUserRef:
      type: object
      description: A compact reference to a platform user.
      properties:
        id:
          type: integer
          nullable: true
        email:
          type: string
          nullable: true
        name:
          type: string
          nullable: true
    Workspace:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
          description: URL-safe identifier; accepted anywhere an id is.
        name:
          type: string
        description:
          type: string
          nullable: true
        status:
          type: string
          description: The workspace's lifecycle state, e.g. `active` or `archived`.
        icon:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        owner_id:
          type: integer
          description: The user who owns the workspace.
        role:
          type: string
          nullable: true
          description: The CALLER's role in this workspace — e.g. `owner`, `member`,
            `viewer`. A rule-group member with no explicit membership row reports
            `member`. Null when the caller has no role in it.
        member_count:
          type: integer
          description: Explicit memberships on the workspace.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceTemplate:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        icon:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        system_template:
          type: boolean
          description: True for the platform-provided set, false for the tenant's
            own.
        structure:
          type: object
          description: The blueprint a workspace created from this template receives
            — sections, task lists and seed content. `{}` when the template defines
            none.
        created_at:
          type: string
          format: date-time
    WorkspaceMessage:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        title:
          type: string
        body:
          type: string
        external_id:
          type: string
          nullable: true
          description: Caller-owned durable publication identity when one was supplied.
        internal_only:
          type: boolean
          description: Excluded from the client digest and the client portal when
            true.
        pinned:
          type: boolean
        author:
          "$ref": "#/components/schemas/WorkspaceUserRef"
        comment_count:
          type: integer
          description: Comments on the thread. On the detail endpoint this is the
            TRUE total, not the size of the `comments` page returned beside it.
        web_url:
          type: string
          format: uri
          description: Browser URL for the message in the tenant.
        attachments:
          type: array
          description: Present on detail and mutation responses; omitted from the
            message-board list.
          items:
            "$ref": "#/components/schemas/WorkspaceMessageAttachment"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceMessageAttachment:
      type: object
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        checksum:
          type: string
          description: Base64-encoded Active Storage checksum for durable client-side
            deduplication.
        inline_playable:
          type: boolean
        inline_url:
          type: string
          format: uri
        download_url:
          type: string
          format: uri
        created_at:
          type: string
          format: date-time
    WorkspaceMessageComment:
      type: object
      properties:
        id:
          type: integer
        body:
          type: string
        internal_only:
          type: boolean
        author:
          type: object
          description: 'The commenter. A reply posted through the Workspace Client
            Portal has no platform account: `id` is null, `email` is the client''s
            address and `external` is true.'
          properties:
            id:
              type: integer
              nullable: true
            email:
              type: string
              nullable: true
            name:
              type: string
              nullable: true
            external:
              type: boolean
        created_at:
          type: string
          format: date-time
    WorkspaceTask:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        list_id:
          type: integer
          nullable: true
          description: The task list this task sits in.
        title:
          type: string
        notes:
          type: string
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        completed:
          type: boolean
        assignee:
          allOf:
          - "$ref": "#/components/schemas/WorkspaceUserRef"
          nullable: true
          description: Null when the task is unassigned.
        ai_drafted:
          type: boolean
          description: True when the task was proposed by the Workspace agent.
        internal_only:
          type: boolean
        recurring_cadence:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceCheckIn:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        question:
          type: string
        cadence:
          type: string
          description: How often the question is asked, e.g. `weekly`.
        active:
          type: boolean
        next_run_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    WorkspaceCheckInDraft:
      type: object
      description: An AI-drafted check-in response of the caller's, awaiting approval.
      properties:
        id:
          type: integer
          description: Pass this as `response_id` to the approve endpoint.
        question:
          type: string
          nullable: true
          description: The check-in question this drafts an answer to.
        response_text:
          type: string
          nullable: true
        drafted_at:
          type: string
          format: date-time
          nullable: true
        ai_drafted:
          type: boolean
    WorkspaceEvent:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        title:
          type: string
        location:
          type: string
          nullable: true
          description: Free text; this is where meeting links usually live.
        all_day:
          type: boolean
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          nullable: true
        internal_only:
          type: boolean
          description: Excluded from the client digest and the client portal when
            true. Carried so a client-facing calendar can filter before publishing.
        created_at:
          type: string
          format: date-time
    WorkspaceHillChart:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        scope_label:
          type: string
          description: The named piece of work this chart tracks.
        position:
          type: integer
          description: Position on the hill, 0-100. The uphill half is "figuring it
            out", the downhill half "making it happen".
        status:
          type: string
          enum:
          - active
          - archived
        ai_proposed_position:
          type: integer
          nullable: true
          description: The agent's suggested position, when one is pending.
        ai_proposal_pending:
          type: boolean
          description: True when a suggestion is awaiting a human decision.
        updated_by_agent_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    WorkspaceReaction:
      type: object
      properties:
        id:
          type: integer
        emoji:
          type: string
        reactable_type:
          type: string
          enum:
          - WorkspaceMessage
          - WorkspaceMessageComment
          - Task
        reactable_id:
          type: integer
        user:
          "$ref": "#/components/schemas/WorkspaceUserRef"
        created_at:
          type: string
          format: date-time
tags:
- name: Workspace
  description: Workspace app — project workspaces and their canvas sections (message
    board, tasks, recurring check-ins, schedule, hill charts, reactions), plus the
    workspace templates and the client digest. API tokens require read:workspace for
    GET/HEAD and write:workspace for mutations; app, tenant, membership, role, and
    feature checks still apply.
- name: Frontline Execution
  description: Frontline Execution worker surface — shift pass-down (handover between
    shifts)
- name: Live Boards
  description: Live Boards — boards with their current readings, and the rotations
    built from them (read-only; the app is metered on active board objects, so no
    write endpoints are offered)
- name: Surveys
  description: Surveys app — the surveys assigned to the caller, a survey with its
    questions, submitting a response, the anonymous feedback channel, and anonymity-safe
    aggregate results plus reminders for the people who run a survey. Per-survey authorization
    is Surveys::AccessPolicy, so the results payload differs by tier; raw per-respondent
    answers are never returned.
- name: Ideas
  description: Ideas app dashboard (totals, stage counts, top-voted / recent / most-discussed)
- name: Comms Hub
  description: Communications (Comms Hub / News Feed) home dashboard — unread/ack/verified
    counts, open poll, latest posts, must-read status, trending topics (read-only)
- name: Recognitions
  description: Recognitions app dashboard — received/given/nomination stats, the activity
    stream, my awards, my nominations, trending recognition, active programs, the
    running award cycle and this month's top recipients (read-only)
- name: Company Store
  description: 'Company Store app — the employee rewards storefront. Dashboard: the
    wallet, the four stat tiles, the "within reach" affordability carousel, featured
    and saved items, the 30-day points-activity roll-up, recent orders, the first-run
    earn on-ramp and the manager-only team-approvals widget. Catalog: the paginated
    grid with the web page''s own category / collection / featured / search filters,
    its six sort strategies and region scoping, the filter sheet with counts, and
    one item''s detail (photo strip, variant groups, per-method payment availability,
    Item Details, related strip). Orders: order history with per-status counts and
    one order''s full detail (derived lifecycle timeline, reward payload, shipping,
    available actions). Approvals: the redemption approval queue plus its approve
    / decline decisions — the one write surface here, and the one endpoint group open
    ONLY to redemption approvers (a designated approver-group member, or a line manager
    with direct reports when no group is configured). Everything else is read-only'
- name: Wikis
  description: Wikis knowledge-base dashboard, browse tree, and search (read-only)
- name: Libraries
  description: 'Libraries app — curated link / file / media collections. The All Libraries
    index mirrors the web page card-for-card (cover, mark, kind, category and item
    counts, last-updated, capability flags) with the all / disabled filters, the three
    orders (Admin order · A → Z · Recently updated) and pagination; disabled libraries
    always sort last. Visibility is audience-scoped — a member never sees a disabled
    library or one whose audience rules exclude them. Libraries administrators can
    additionally DISABLE a library (POST /libraries/{id}/disable) — a reversible hide
    that mirrors the web control, is idempotent rather than a toggle, and requires
    the `write:libraries` scope. The library detail read (GET /libraries/{id}) returns
    the whole library screen in one call — the header, every category with the layout
    and sort order its admin set on it, and every item with its format, provenance,
    resolved destination, download and pre-gated kebab actions. It is reachable only
    by a caller who can access that library: a restricted, disabled-and-unmanageable,
    cross-tenant or non-existent id all return the same 404.'
- name: Training
  description: Training app learner "My Training" home — stats, continue learning,
    upcoming instructor-led sessions, and the status-filtered course-enrollment list
    (read-only)
- name: Leader Rounds
  description: Leader Rounds — structured leader rounding on staff. The due list,
    the capture form's typed question set, round detail across both visibility tiers,
    the derived stoplight issue ledger, coverage rolled up the reporting line, and
    the subject-tier close-the-loop view (rounds about you, what you raised, what
    you own).
- name: Authentication
  description: Mobile app authentication endpoints
- name: Two-Factor Authentication
  description: Two-factor authentication self-service management
- name: Passwordless Authentication
  description: Passwordless authentication via magic links and codes
- name: API Token Management
  description: Personal API token creation and management
- name: Impersonation
  description: Impersonation token management for service accounts to act on behalf
    of users
- name: Service Desk
  description: Support ticket management and help desk operations
- name: RFP Desk
  description: RFP Desk — answer library, RFP responses and their sections, and bid/no-bid
    assessments
- name: TinyTake
  description: Screen capture, video recording, and file management for the TinyTake
    marketplace app
- name: Employee Compensation
  description: Employee compensation profile, history, and insights management
- name: Compensation Requests
  description: Compensation change request lifecycle management
- name: Users
  description: User profile and preferences management
- name: User Profile Management
  description: Enhanced user profile management with actions and bulk operations
- name: Account Settings
  description: Comprehensive account settings management including security, privacy,
    and communication preferences
- name: Leave Management
  description: Employee leave requests, balances, and leave type management
- name: Forms
  description: Form templates, submissions, and mobile rendering with offline support
- name: Marketplace Apps
  description: Enabled marketplace apps for mobile app launcher
- name: System Modules
  description: Core platform features that can be enabled/disabled per business
- name: Skills & Certifications
  description: Employee skills management, certifications, and competency tracking
- name: ActionCue Webhooks
  description: ActionCue external processing system webhook endpoints
- name: Shifts
  description: Shift management and scheduling
- name: Attendance
  description: Time tracking and attendance management
- name: Timesheets
  description: Employee timesheet management and submission
- name: Timesheet Entries
  description: Individual timesheet entry management and editing
- name: Pay Information
  description: Employee pay summaries, history, and year-to-date information
- name: Paychecks
  description: Employee paycheck access and detailed pay stub information
- name: Pay Periods
  description: Pay period management and detailed period information
- name: Manual Time Entries
  description: Manual time entry creation and management for missed punches
- name: EPMS Dashboard
  description: Employee Performance Management System dashboard with aggregated goals,
    reviews, feedback, and meetings
- name: EPMS Goals
  description: Employee goal management including creation, tracking, progress updates,
    and completion
- name: EPMS Performance Reviews
  description: Performance review lifecycle management including creation, self-assessments,
    approvals, and goal linking
- name: EPMS Continuous Feedback
  description: Continuous feedback system for real-time performance feedback including
    praise, recognition, and coaching
- name: EPMS Development Plans
  description: Employee development plan management with goal tracking and skill development
- name: EPMS Meetings
  description: One-on-one meetings and check-ins scheduling and management
- name: EPMS Competency Frameworks
  description: Competency framework definitions and structure (read-only for most
    users)
- name: EPMS Competency Assessments
  description: Competency assessment creation and management with ratings and evaluations
- name: Notifications
  description: User notification management — mark read/unread, archive, bulk operations
    (mobile support)
- name: Messaging
  description: Inbox → Messages (Direct Messages) — 1:1 / small-group conversations,
    message compose (text + attachments), edit, delete, participants, and recipient
    picker
- name: Chat
  description: Chat app — direct / group / channel rooms, async message posting with
    media, reactions, threads, important-message acknowledgements, mentions, search,
    presence, and Pusher real-time bootstrap
- name: Safety Hub
  description: Incidents, safety observations, toolbox talks, certifications, and
    the personal submission feed
- name: Inspections
  description: Inspections — role-aware list endpoint (personal by default, team feed
    via `?team=true` for managers)
- name: News Feed
  description: News Feed posts (Update / Question / Poll), pinning, comments, reactions,
    read receipts, and must-read acknowledgements
- name: Broadcasts
  description: Company broadcasts — the caller's received inbox (Dashboard parity)
    with all/unread/critical/acknowledge subfilters, plus create/publish/acknowledge
- name: Alerts
  description: Emergency alerts — the caller's received inbox + pending approvals,
    with all/urgent/acknowledge/safety_check_in subfilters and per-caller response
    state
- name: Approvals
  description: Shared approve/reject for Comms Hub approval requests — one endpoint
    pair for both broadcasts and alerts (act on the request id from a source's pending_approvals)
- name: Audience Options
  description: '"Add to audience" picker options shared by the broadcast + alert composers
    — departments / locations / job titles / roles / groups, each with a user_count
    (search + pagination)'
- name: AI Notepad
  description: AI Notepad — meetings and notes with their transcripts, AI artifacts,
    action items, notebooks, sharing and streamed AI chat. A row is both a recorded
    meeting and a plain typed note; `source` says which. Bearer token; this namespace
    declares no per-endpoint token scopes — the gate is that the AI Notepad app is
    enabled for the business and visible to the caller — except send_to_chat, which
    also needs write:chat.
