openapi: 3.1.0
info:
  title: KOVITEL eSIM Middleware API
  version: 0.1.0
  description: |
    B2B integration layer between partner IoT platforms and the Viettel M2M core
    network. Status changes and batch operations are asynchronous: the API returns
    a job id immediately and processes Viettel reverse-API/RPA calls in a BullMQ
    worker. All `/api/v1` endpoints require the `x-api-key` header.
  contact:
    name: KOVITEL Platform Team
servers:
  - url: https://api.kovitel.com
    description: Production
  - url: http://localhost:3001
    description: Local development

security:
  - ApiKeyAuth: []

tags:
  - name: Pool
  - name: SIM
  - name: Jobs
  - name: Webhooks

paths:
  /api/v1/pool/status:
    get:
      tags: [Pool]
      summary: Get data pool status
      operationId: getPoolStatus
      parameters:
        - in: query
          name: poolId
          schema: { type: string, format: uuid }
          required: false
          description: Defaults to the client's active pool.
      responses:
        '200':
          description: Pool status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PoolStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/pool/telemetry:
    get:
      tags: [Pool]
      summary: Get the latest raw telemetry snapshot
      operationId: getPoolTelemetry
      responses:
        '200':
          description: Latest telemetry snapshot captured by the scheduled sync
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/sim/inventory:
    get:
      tags: [SIM]
      summary: List eSIM profiles
      operationId: listInventory
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [AVAILABLE, ACTIVE, SUSPENDED, RELEASED]
            default: AVAILABLE
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: cursor
          schema: { type: string, format: uuid }
          description: Opaque cursor taken from `pagination.nextCursor`.
      responses:
        '200':
          description: Inventory page
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SimProfile' }
                  pagination:
                    type: object
                    required: [limit, hasMore, nextCursor]
                    properties:
                      limit: { type: integer }
                      hasMore: { type: boolean }
                      nextCursor: { type: [string, 'null'], format: uuid }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/sim/allocate:
    post:
      tags: [SIM]
      summary: Allocate an eSIM from the pool to a device
      operationId: allocateSim
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [deviceId]
              properties:
                deviceId: { type: string, maxLength: 128, example: smart-meter-001 }
                iccid:
                  type: string
                  pattern: '^[0-9]{18,22}$'
                  description: Optional. When omitted the oldest available ICCID is locked and allocated.
      responses:
        '201':
          description: eSIM allocated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SimProfile' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /api/v1/sim/{iccid}/status:
    put:
      tags: [SIM]
      summary: Change eSIM status (asynchronous)
      operationId: changeSimStatus
      parameters:
        - $ref: '#/components/parameters/Iccid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [ACTIVATE, SUSPEND, RESUME, RELEASE]
      responses:
        '202':
          description: Job accepted. Poll `/api/v1/jobs/{jobId}` for the result.
          content:
            application/json:
              schema:
                type: object
                required: [jobId, status, iccid, action, deduplicated]
                properties:
                  jobId: { type: string, format: uuid }
                  status: { type: string, enum: [PENDING, PROCESSING] }
                  iccid: { type: string }
                  action: { type: string }
                  deduplicated:
                    type: boolean
                    description: True when an in-flight job for the same action was reused.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /api/v1/sim/{iccid}/usage:
    get:
      tags: [SIM]
      summary: Query near-real-time data usage
      description: Tries the Viettel reverse API and falls back to the last cached value.
      operationId: getSimUsage
      parameters:
        - $ref: '#/components/parameters/Iccid'
      responses:
        '200':
          description: Usage
          content:
            application/json:
              schema:
                type: object
                required: [iccid, dataUsedMB, source]
                properties:
                  iccid: { type: string }
                  msisdn: { type: [string, 'null'] }
                  status: { type: string, enum: [AVAILABLE, ACTIVE, SUSPENDED, RELEASED] }
                  dataUsedMB: { type: integer }
                  dataLimitMB: { type: [integer, 'null'] }
                  remainingMB: { type: [integer, 'null'] }
                  usagePercent: { type: [number, 'null'] }
                  source: { type: string, enum: [VIETTEL, CACHE] }
                  lastSyncedAt: { type: [string, 'null'], format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /api/v1/sim/batch/status:
    post:
      tags: [SIM]
      summary: Change status for many eSIMs (asynchronous)
      operationId: batchChangeSimStatus
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iccids, action]
              properties:
                iccids:
                  type: array
                  minItems: 1
                  maxItems: 500
                  uniqueItems: true
                  items: { type: string, pattern: '^[0-9]{18,22}$' }
                action: { type: string, enum: [ACTIVATE, SUSPEND] }
      responses:
        '202':
          description: Batch accepted
          content:
            application/json:
              schema:
                type: object
                required: [batchJobId, status, total, message]
                properties:
                  batchJobId: { type: string, format: uuid }
                  status: { type: string }
                  total: { type: integer }
                  message: { type: string, example: Processing in background }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /api/v1/sim/batch/{batchJobId}:
    get:
      tags: [SIM, Jobs]
      summary: Get aggregate progress of a batch
      operationId: getBatchSummary
      parameters:
        - in: path
          name: batchJobId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Batch summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/jobs:
    get:
      tags: [Jobs]
      summary: List jobs for the authenticated client
      operationId: listJobs
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [PENDING, PROCESSING, COMPLETED, FAILED] }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job page
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Job' }
                  pagination:
                    type: object
                    properties:
                      limit: { type: integer }
                      hasMore: { type: boolean }
                      nextCursor: { type: [string, 'null'], format: uuid }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/jobs/{jobId}:
    get:
      tags: [Jobs]
      summary: Get a single job
      operationId: getJob
      parameters:
        - in: path
          name: jobId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      summary: Get webhook configuration
      operationId: getWebhookConfig
      responses:
        '200':
          description: Webhook config with masked secret
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookConfig' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Webhooks]
      summary: Register or update the webhook URL
      description: |
        The signing secret is returned only when it is generated for the first time
        or when `rotateSecret` is true. Store it immediately.
      operationId: updateWebhookConfig
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, maxLength: 2048 }
                rotateSecret: { type: boolean, default: false }
      responses:
        '200':
          description: Updated config
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookConfig' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/webhooks/test:
    post:
      tags: [Webhooks]
      summary: Queue a test webhook delivery
      operationId: testWebhook
      responses:
        '202':
          description: Test delivery queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveryId: { type: string, format: uuid }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key

  parameters:
    Iccid:
      in: path
      name: iccid
      required: true
      schema:
        type: string
        pattern: '^[0-9]{18,22}$'
      example: '8984048000000000001'

  responses:
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Invalid state transition or duplicate allocation
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UpstreamError:
      description: Viettel upstream failure
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: NOT_FOUND }
            message: { type: string }
            details: {}
            requestId: { type: [string, 'null'] }

    PoolStatus:
      type: object
      required:
        - poolId
        - totalQuotaMB
        - usedQuotaMB
        - remainingQuotaMB
        - expiryDate
      properties:
        poolId: { type: string, format: uuid }
        name: { type: string }
        totalQuotaMB: { type: integer }
        usedQuotaMB: { type: integer }
        remainingQuotaMB: { type: integer }
        usagePercent: { type: [number, 'null'] }
        expiryDate: { type: string, format: date-time }
        isActive: { type: boolean }
        expired: { type: boolean }
        sims:
          type: object
          properties:
            available: { type: integer }
            active: { type: integer }
            suspended: { type: integer }
            released: { type: integer }

    SimProfile:
      type: object
      required: [iccid, status]
      properties:
        iccid: { type: string }
        msisdn: { type: [string, 'null'] }
        deviceId: { type: [string, 'null'] }
        status: { type: string, enum: [AVAILABLE, ACTIVE, SUSPENDED, RELEASED] }
        dataUsedMB: { type: integer }
        dataLimitMB: { type: [integer, 'null'] }
        usagePercent: { type: [number, 'null'] }
        poolId: { type: [string, 'null'], format: uuid }
        poolName: { type: [string, 'null'] }
        allocatedAt: { type: [string, 'null'], format: date-time }
        lastSyncedAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Job:
      type: object
      required: [jobId, type, status]
      properties:
        jobId: { type: string, format: uuid }
        type:
          type: string
          enum: [SIM_ACTIVATE, SIM_SUSPEND, SIM_RESUME, SIM_RELEASE, BATCH_STATUS]
        status: { type: string, enum: [PENDING, PROCESSING, COMPLETED, FAILED] }
        payload: { type: object, additionalProperties: true }
        result: { type: [object, 'null'], additionalProperties: true }
        error: { type: [string, 'null'] }
        attempts: { type: integer }
        maxAttempts: { type: integer }
        batchId: { type: [string, 'null'], format: uuid }
        createdAt: { type: string, format: date-time }
        startedAt: { type: [string, 'null'], format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }

    BatchSummary:
      type: object
      required: [batchJobId, status, total, pending, processing, completed, failed, jobs]
      properties:
        batchJobId: { type: string, format: uuid }
        status: { type: string, enum: [PENDING, PROCESSING, COMPLETED, FAILED] }
        action: { type: [string, 'null'] }
        total: { type: integer }
        pending: { type: integer }
        processing: { type: integer }
        completed: { type: integer }
        failed: { type: integer }
        jobs:
          type: array
          items:
            type: object
            properties:
              jobId: { type: string, format: uuid }
              iccid: { type: [string, 'null'] }
              status: { type: string }
              error: { type: [string, 'null'] }
              completedAt: { type: [string, 'null'], format: date-time }
        truncated: { type: boolean }

    WebhookConfig:
      type: object
      properties:
        url: { type: [string, 'null'] }
        configured: { type: boolean }
        secret:
          type: [string, 'null']
          description: Raw secret, only present when newly generated or rotated.
        secretMasked: { type: [string, 'null'] }
