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-sandbox.kovitel.com
    description: Sandbox
  - url: http://localhost:3001
    description: Local development

security:
  - ApiKeyAuth: []

tags:
  - name: Pool
  - name: SIM
  - name: Packages
  - 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' }

  /api/v1/packages:
    get:
      tags: [Packages]
      summary: List active data packages
      operationId: listPackages
      responses:
        '200':
          description: Active package catalog
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DataPackage' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/packages/orders:
    get:
      tags: [Packages]
      summary: List package orders for the authenticated client
      operationId: listPackageOrders
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Package orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PackageOrder' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/packages/orders/{orderId}:
    get:
      tags: [Packages, Jobs]
      summary: Get a package order
      operationId: getPackageOrder
      parameters:
        - in: path
          name: orderId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Package order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PackageOrder' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/packages/{code}:
    get:
      tags: [Packages]
      summary: Get a data package by code
      operationId: getPackage
      parameters:
        - in: path
          name: code
          required: true
          schema: { type: string, pattern: '^[A-Z0-9_-]+$' }
          example: DCAR150
      responses:
        '200':
          description: Data package
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataPackage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/sim/{iccid}/packages:
    get:
      tags: [Packages, SIM]
      summary: List active packages and orders for an eSIM
      operationId: listSimPackages
      parameters:
        - $ref: '#/components/parameters/Iccid'
      responses:
        '200':
          description: Active packages and recent orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  iccid: { type: string }
                  activePackages:
                    type: array
                    items: { $ref: '#/components/schemas/PackageGrant' }
                  orders:
                    type: array
                    items: { $ref: '#/components/schemas/PackageOrder' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Packages, SIM]
      summary: Purchase a data package for an eSIM (asynchronous)
      description: |
        The SIM must be ACTIVE. Returns 202 with orderId and jobId. Use the
        `idempotency-key` header to avoid duplicate orders on retry. Customer
        price is returned as `salePriceVnd`; internal cost is never exposed.
      operationId: purchasePackage
      parameters:
        - $ref: '#/components/parameters/Iccid'
        - in: header
          name: idempotency-key
          required: false
          schema: { type: string, minLength: 8, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PackagePurchaseRequest' }
      responses:
        '202':
          description: Package purchase accepted
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PackageOrder'
                  - type: object
                    properties:
                      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' }

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 }

    DataPackage:
      type: object
      required: [code, name, type, durationDays, salePriceVnd, currency]
      properties:
        code: { type: string, example: DCAR150 }
        name: { type: string, example: DCAR150 }
        type: { type: string, enum: [MAIN, ADDON] }
        dailyQuotaMB: { type: [integer, 'null'], example: 7168 }
        dailyQuotaGB: { type: [number, 'null'], example: 7 }
        durationDays: { type: integer, example: 30 }
        salePriceVnd: { type: integer, example: 300000 }
        currency: { type: string, example: VND }
        description: { type: [string, 'null'] }

    PackageOrder:
      type: object
      required: [orderId, packageCode, status, totalPriceVnd]
      properties:
        orderId: { type: string, format: uuid }
        jobId: { type: [string, 'null'], format: uuid }
        iccid: { type: [string, 'null'] }
        packageCode: { type: string }
        status: { type: string, enum: [PENDING, PROCESSING, COMPLETED, FAILED] }
        quantity: { type: integer }
        unitPriceVnd: { type: integer }
        totalPriceVnd: { type: integer }
        currency: { type: string, example: VND }
        error: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }

    PackageGrant:
      type: object
      properties:
        grantId: { type: string, format: uuid }
        packageCode: { type: string }
        packageName: { type: string }
        dailyQuotaMB: { type: [integer, 'null'] }
        status: { type: string, enum: [ACTIVE, EXPIRED] }
        startsAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }

    PackagePurchaseRequest:
      type: object
      required: [packageCode]
      properties:
        packageCode: { type: string, example: DCAR150 }
        idempotencyKey:
          type: string
          minLength: 8
          maxLength: 128
          description: Optional alternative to the idempotency-key header.

    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'] }
