openapi: 3.1.0
info:
  title: Chinron MSP Public API
  version: "1.0.0"
  description: |
    Public REST API for MSPs to manage end-user lifecycle (create / update /
    deactivate) across their client organizations, pull per-client training,
    phishing, and risk reporting, and integrate Chinron with their PSA, RMM, or
    automation tooling (Make, Zapier, n8n, Power Automate) directly.

    ## Authentication
    Every request must send your MSP API key as a Bearer token:

        Authorization: Bearer msp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

    Generate and rotate keys in **MSP Settings → API & Webhooks**. A key is shown
    once at creation. Regenerating revokes the previous key.

    ## Scoping
    A key belongs to your MSP organization and may act on your own org or any
    client organization whose parent is your MSP. `clientId` may be the client's
    organization UUID or its tenant slug.

    ## Scopes
    Keys are granted a fixed set of scopes at generation. If you generated a key
    before the client/training scopes existed, **regenerate it** in
    MSP Settings → API to pick them up.

    | Scope | Grants |
    |---|---|
    | `users:read` | List users (implied by `users:write`) |
    | `users:write` | Create, update, deactivate, reactivate users |
    | `reports:read` | Training, phishing, and risk-score summaries |
    | `clients:read` | List client organizations (implied by `clients:write`) |
    | `clients:write` | Register new client organizations |
    | `training:write` | Trigger the AI training wizard for a client |

    Newly generated keys carry all six scopes.

    ## Rate limiting
    Requests are limited per key (default 600/min). Responses include
    `X-RateLimit-Limit` and `X-RateLimit-Remaining`; `429` includes `Retry-After`.

    ## Idempotency
    Creating a user is idempotent on email: repeating a create returns the
    existing user (`created: false`) instead of erroring, so retries are safe.

servers:
  - url: https://au.chinron.io/api/public/v1
    description: Production
  - url: https://staging.chinron.io/api/public/v1
    description: Staging
  - url: http://localhost:3000/api/public/v1
    description: Local development

security:
  - bearerAuth: []

tags:
  - name: Identity
  - name: Users
  - name: Reports
  - name: Training

paths:
  /me:
    get:
      tags: [Identity]
      summary: Identify the organization a key belongs to
      description: Confirms the API key and returns the owning MSP organization and granted scopes.
      responses:
        "200":
          description: Key is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      organization:
                        $ref: '#/components/schemas/Organization'
                      scopes:
                        type: array
                        items: { type: string }
                        example: ["users:write"]
        "401": { $ref: '#/components/responses/Unauthorized' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients:
    get:
      tags: [Identity]
      summary: List client organizations
      description: |
        Lists the client organizations this key can act on (all orgs parented
        by your MSP). Use it to map Chinron clients to PSA companies and to
        drive per-client sync or reporting loops. `active_users` supports seat
        reconciliation. Requires the `clients:read` scope (implied by
        `clients:write`).
      responses:
        "200":
          description: Client organizations
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      clients:
                        type: array
                        items: { $ref: '#/components/schemas/ClientOrganization' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "429": { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Identity]
      summary: Register a client organization
      description: |
        Onboards a new client organization under your MSP and invites its first
        admin (an invitation email is queued). The client inherits your MSP
        branding automatically. Requires the `clients:write` scope.

        Idempotent on client **name** within your MSP: if an active client with
        the same name already exists, it is returned with `created: false` and a
        200 instead of creating a duplicate.

        After creating a client, add its users
        (`POST /clients/{clientId}/users`) and trigger its training
        (`POST /clients/{clientId}/training-paths`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateClientRequest' }
      responses:
        "201":
          description: Client created (admin invite queued)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateClientResponse' }
        "200":
          description: Client with this name already existed (idempotent no-op)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateClientResponse' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "409": { $ref: '#/components/responses/Conflict' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/training-paths:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    post:
      tags: [Training]
      summary: Trigger the AI training wizard for a client
      description: |
        Builds a full training program for the client — the same build the portal
        wizard runs — including paired phishing and deepfake paths, and assigns it
        to the client's users. Requires the `training:write` scope.

        Generation and pairing are asynchronous, so this returns **202 Accepted**
        with the created path id. The client should already have users
        (see `POST /clients/{clientId}/users`); with no users the path is created
        but assigned to nobody.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TriggerTrainingPathRequest' }
      responses:
        "202":
          description: Training path build accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TriggerTrainingPathResponse' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/users:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [Users]
      summary: List users in a client organization
      description: |
        Paginated list for reconciliation. Pass `email` for an exact
        (case-insensitive) lookup — e.g. resolving an offboarding ticket's
        email address to a user id before calling deactivate.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: email
          in: query
          description: Exact email match (case-insensitive).
          schema: { type: string, format: email }
      responses:
        "200":
          description: List of users
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      users:
                        type: array
                        items: { $ref: '#/components/schemas/UserListItem' }
                      pagination: { $ref: '#/components/schemas/Pagination' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Users]
      summary: Create (invite) a user
      description: |
        Creates an active user in the client org and queues an invitation email.
        Idempotent on email. Training is auto-assigned by Chinron's scheduler:
        if a `department` is supplied the user receives that department's training
        path; otherwise the organization's default path.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateUserRequest' }
      responses:
        "201":
          description: User created (invite queued)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateUserResponse' }
        "200":
          description: User already existed in this org (idempotent no-op)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateUserResponse' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "409": { $ref: '#/components/responses/Conflict' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/users/batch:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    post:
      tags: [Users]
      summary: Create up to 200 users in one request
      description: Processes each user idempotently and returns a per-row result. Page through batches for larger imports.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [users]
              properties:
                users:
                  type: array
                  maxItems: 200
                  items: { $ref: '#/components/schemas/CreateUserRequest' }
      responses:
        "200":
          description: Batch processed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      summary:
                        type: object
                        properties:
                          total: { type: integer }
                          created: { type: integer }
                          existed: { type: integer }
                          failed: { type: integer }
                      results:
                        type: array
                        items:
                          type: object
                          properties:
                            email: { type: string }
                            status: { type: string, enum: [created, exists, error] }
                            id: { type: string, format: uuid }
                            error: { type: string }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/users/{id}:
    parameters:
      - $ref: '#/components/parameters/ClientId'
      - $ref: '#/components/parameters/UserId'
    patch:
      tags: [Users]
      summary: Update a user
      description: |
        Update a user's name, role, and/or department. Only non-privileged users
        (`employee`, `manager`) can be modified — the API cannot touch admins.
        Supplying `department` MOVES the user: existing department memberships
        are replaced, and the auto-assign scheduler picks up the new
        department's training path on its next run.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name: { type: string }
                role: { type: string, enum: [employee, manager] }
                department:
                  type: string
                  description: Department UUID or name. Replaces existing department memberships.
      responses:
        "200":
          description: User updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      user: { $ref: '#/components/schemas/UserSummary' }
                  message: { type: string }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/users/{id}/deactivate:
    parameters:
      - $ref: '#/components/parameters/ClientId'
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [Users]
      summary: Deactivate a user
      description: Sets the user inactive (no hard delete). Idempotent.
      responses:
        "200":
          description: Deactivated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ActiveStateResponse' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/users/{id}/reactivate:
    parameters:
      - $ref: '#/components/parameters/ClientId'
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [Users]
      summary: Reactivate a user
      responses:
        "200":
          description: Reactivated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ActiveStateResponse' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/reports/training-summary:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [Reports]
      summary: Training completion summary
      description: |
        Org-level training aggregates (assignments, completion rate, overdue
        count) plus a paginated per-user rollup. Only active users are counted.
        Requires the `reports:read` scope.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Training summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      summary: { $ref: '#/components/schemas/TrainingSummary' }
                      users:
                        type: array
                        items: { $ref: '#/components/schemas/TrainingUserRollup' }
                      pagination: { $ref: '#/components/schemas/Pagination' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/reports/phishing-summary:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [Reports]
      summary: Phishing simulation summary
      description: |
        Campaign funnel (sent / opened / clicked / reported, with rates) over a
        rolling window, plus the 10 most recent campaigns. Scanner hits are
        excluded from open/click counts. Requires the `reports:read` scope.
      parameters:
        - name: months
          in: query
          description: Rolling window in months.
          schema: { type: integer, minimum: 1, maximum: 36, default: 12 }
      responses:
        "200":
          description: Phishing summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      window_months: { type: integer }
                      summary: { $ref: '#/components/schemas/PhishingSummary' }
                      recent_campaigns:
                        type: array
                        items: { $ref: '#/components/schemas/PhishingCampaignRollup' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

  /clients/{clientId}/reports/risk-score:
    parameters:
      - $ref: '#/components/parameters/ClientId'
    get:
      tags: [Reports]
      summary: Human-risk scores
      description: |
        Per-user human-risk scores (0–100, higher = riskier) computed from
        phishing behaviour, training completion, deepfake detection accuracy,
        and course-rushing signals, plus an org average and risk-level
        distribution. Computed on demand — allow a few seconds for large
        organizations. Requires the `reports:read` scope.
      responses:
        "200":
          description: Risk scores
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      summary: { $ref: '#/components/schemas/RiskSummary' }
                      users:
                        type: array
                        items: { $ref: '#/components/schemas/RiskUser' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "429": { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: msp_live key

  parameters:
    ClientId:
      name: clientId
      in: path
      required: true
      description: Client organization UUID or tenant slug.
      schema: { type: string }
    UserId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  schemas:
    Organization:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        tenant_id: { type: string }
        type: { type: string, example: msp }
    ClientOrganization:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        tenant_id: { type: string, description: Tenant slug — usable as clientId in paths. }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        active_users: { type: integer }
    CreateClientRequest:
      type: object
      required: [name, contact_email, admin_user]
      properties:
        name: { type: string, description: Client organization name. Idempotency key within your MSP. }
        contact_email: { type: string, format: email, description: Primary contact email for the client org. }
        contact_name: { type: string, description: Optional primary contact name. }
        industry: { type: string, description: Optional industry (tailors the training path). }
        admin_user:
          type: object
          required: [email, name]
          description: The client's first admin. An invitation email is queued to them.
          properties:
            email: { type: string, format: email }
            name: { type: string }
    ClientSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, description: Tenant slug — usable as clientId in paths. }
        name: { type: string }
        industry: { type: string, nullable: true }
        status: { type: string, example: active }
    CreateClientResponse:
      type: object
      properties:
        success: { type: boolean }
        created: { type: boolean, description: true if newly created, false on an idempotent match. }
        message: { type: string }
        data:
          type: object
          properties:
            client: { $ref: '#/components/schemas/ClientSummary' }
            admin_user:
              type: object
              properties:
                id: { type: string, format: uuid }
                email: { type: string }
                invitation_sent: { type: boolean }
    TriggerTrainingPathRequest:
      type: object
      description: All fields optional; sensible defaults are applied.
      properties:
        pathType: { type: string, enum: [generic, industry_specific, hybrid], default: generic }
        startDate: { type: string, format: date, description: Path start (defaults to the first of next month). }
        targetDepartment: { type: string, nullable: true, description: Department UUID/name to target; omit for all staff. }
        autoAssignNewUsers: { type: boolean, default: false, description: Auto-assign this path to users added later. }
        industry: { type: string, nullable: true, description: Override the client's industry for tailoring. }
    TriggerTrainingPathResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            pathId: { type: string, format: uuid }
            pathName: { type: string }
            pathStartDate: { type: string, format: date-time }
            pathEndDate: { type: string, format: date-time }
            totalCourses: { type: integer }
            usersAssigned: { type: integer }
            cadenceWeeks: { type: integer }
            pathMode: { type: string }
            isFallback: { type: boolean }
            phishingPathId: { type: string, format: uuid, nullable: true }
            deepfakePathId: { type: string, format: uuid, nullable: true }
            phishQuizPathId: { type: string, format: uuid, nullable: true }
    CreateUserRequest:
      type: object
      required: [email, name]
      properties:
        email: { type: string, format: email }
        name: { type: string }
        role: { type: string, enum: [employee, manager], default: employee }
        department:
          type: string
          description: Optional department UUID or name. Drives which training path is auto-assigned.
    UserSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        role: { type: string }
        status: { type: string, example: invited }
        is_active: { type: boolean }
    UserListItem:
      allOf:
        - $ref: '#/components/schemas/UserSummary'
        - type: object
          properties:
            created_at: { type: string, format: date-time }
    CreateUserResponse:
      type: object
      properties:
        success: { type: boolean }
        created: { type: boolean, description: true if a new user was created, false if it already existed }
        data:
          type: object
          properties:
            user: { $ref: '#/components/schemas/UserSummary' }
        message: { type: string }
    ActiveStateResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            user:
              type: object
              properties:
                id: { type: string, format: uuid }
                email: { type: string }
                is_active: { type: boolean }
        message: { type: string }
    Pagination:
      type: object
      properties:
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
    TrainingSummary:
      type: object
      properties:
        active_users: { type: integer }
        total_assignments: { type: integer }
        completed: { type: integer }
        in_progress: { type: integer }
        overdue: { type: integer }
        scheduled:
          type: integer
          description: Assignments from a cadenced training path not yet released (no due date yet).
        completion_rate:
          type: [number, "null"]
          description: Percentage of RELEASED assignments completed (one decimal). Null when nothing has been released.
    TrainingUserRollup:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        assignments: { type: integer }
        completed: { type: integer }
        overdue: { type: integer }
    PhishingSummary:
      type: object
      properties:
        campaigns: { type: integer }
        campaigns_completed: { type: integer }
        sent: { type: integer }
        opened: { type: integer }
        clicked: { type: integer }
        reported: { type: integer }
        open_rate: { type: [number, "null"] }
        click_rate: { type: [number, "null"] }
        report_rate: { type: [number, "null"] }
    PhishingCampaignRollup:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        status: { type: string, enum: [scheduled, in_progress, completed, cancelled] }
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        sent: { type: integer }
        clicked: { type: integer }
        reported: { type: integer }
    RiskSummary:
      type: object
      properties:
        users_scored: { type: integer }
        average_risk_score: { type: [integer, "null"] }
        risk_level: { type: string, example: medium }
        distribution:
          type: object
          additionalProperties: { type: integer }
          example: { low: 12, medium: 30, high: 5 }
    RiskUser:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string }
        risk_score: { type: [integer, "null"] }
        risk_level: { type: string }
    Error:
      type: object
      properties:
        success: { type: boolean, example: false }
        error: { type: string }

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, or revoked API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Key lacks the required scope, or is not authorized for this client org
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found in this organization
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Email already belongs to a user in another organization
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the limit resets
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
