openapi: 3.1.0
info:
  title: ExamNova Companion API
  version: 1.0.0
  description: Versioned, tenant-aware API for the ExamNova Android and iOS companion applications. The Laravel domain services remain authoritative.
servers:
  - url: https://examnova.ovion-tech.xyz/api/mobile/v1
    description: Official ExamNova demo and discovery origin
  - url: https://{customerDomain}/api/mobile/v1
    description: Verified self-hosted installation
    variables:
      customerDomain:
        default: examnova.example.edu
security:
  - bearerAuth: []
tags:
  - name: Discovery
  - name: Authentication
  - name: Session
  - name: Devices
  - name: Notifications
  - name: Student
  - name: Parent
  - name: Staff
x-compatibility-headers: &compatibilityHeaders
  - {$ref: '#/components/parameters/AppVersion'}
  - {$ref: '#/components/parameters/ApiVersion'}
x-attempt-headers: &attemptHeaders
  - {$ref: '#/components/parameters/AppVersion'}
  - {$ref: '#/components/parameters/ApiVersion'}
  - {$ref: '#/components/parameters/AttemptId'}
paths:
  /discovery:
    get:
      tags: [Discovery]
      security: []
      summary: Discover the installation, brand, supported API, app versions, and controlled links
      responses:
        '200':
          description: Verified ExamNova discovery document
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Discovery'}
  /auth/login:
    post:
      tags: [Authentication]
      security: []
      summary: Authenticate a user and bind or validate a mobile device
      parameters: *compatibilityHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, device]
              properties:
                email: {type: string, format: email}
                password: {type: string, format: password, minLength: 8, writeOnly: true}
                device: {$ref: '#/components/schemas/DeviceRegistration'}
      responses:
        '200': {$ref: '#/components/responses/SessionResponse'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402': {description: Installation needs an active subscription}
        '409': {description: Multi-factor challenge required or passkey unavailable}
        '426': {$ref: '#/components/responses/UpgradeRequired'}
  /auth/verify:
    post:
      tags: [Authentication]
      security: []
      summary: Complete a one-time-code or TOTP challenge
      parameters: *compatibilityHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challenge_token, code]
              properties:
                challenge_token: {type: string, writeOnly: true}
                code: {type: string, writeOnly: true}
      responses:
        '200': {$ref: '#/components/responses/SessionResponse'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '422': {$ref: '#/components/responses/ValidationError'}
  /auth/refresh:
    post:
      tags: [Authentication]
      security: []
      summary: Rotate a refresh token and issue a new token pair
      parameters: *compatibilityHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: {type: string, writeOnly: true}
      responses:
        '200': {$ref: '#/components/responses/SessionResponse'}
        '401': {$ref: '#/components/responses/Unauthorized'}
  /auth/logout:
    post:
      tags: [Session]
      summary: Revoke the current access token
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Message'}
        '401': {$ref: '#/components/responses/Unauthorized'}
  /bootstrap:
    get:
      tags: [Session]
      summary: Load current role, permission-derived capabilities, tenant brand, offline policy, and compatibility
      parameters: *compatibilityHeaders
      responses:
        '200':
          description: Session bootstrap
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: {$ref: '#/components/schemas/Bootstrap'}
        '401': {$ref: '#/components/responses/Unauthorized'}
  /devices:
    get:
      tags: [Devices]
      summary: List the current user's authorized devices in the active tenant
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Collection'}
  /devices/push:
    put:
      tags: [Devices]
      summary: Register the current device's native FCM or APNs token
      parameters: *compatibilityHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, token]
              properties:
                provider: {type: string, enum: [fcm, apns]}
                token: {type: string, writeOnly: true, maxLength: 4096}
      responses:
        '200': {$ref: '#/components/responses/Message'}
        '422': {$ref: '#/components/responses/ValidationError'}
  /devices/push-receipts:
    post:
      tags: [Devices]
      summary: Record a received, opened, or dismissed push receipt
      parameters: *compatibilityHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message_uuid, status]
              properties:
                message_uuid: {type: string, format: uuid}
                status: {type: string, enum: [received, opened, dismissed]}
      responses:
        '200': {$ref: '#/components/responses/Message'}
  /devices/{publicId}:
    delete:
      tags: [Devices]
      summary: Revoke a device and all its active mobile tokens
      parameters:
        - {$ref: '#/components/parameters/AppVersion'}
        - {$ref: '#/components/parameters/ApiVersion'}
        - name: publicId
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        '200': {$ref: '#/components/responses/Message'}
        '404': {$ref: '#/components/responses/NotFound'}
  /notifications:
    get:
      tags: [Notifications]
      summary: List tenant-scoped notifications for the current user
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Collection'}
  /notifications/{id}/read:
    patch:
      tags: [Notifications]
      summary: Mark an owned tenant-scoped notification as read
      parameters:
        - {$ref: '#/components/parameters/AppVersion'}
        - {$ref: '#/components/parameters/ApiVersion'}
        - {name: id, in: path, required: true, schema: {type: string}}
      responses:
        '200': {$ref: '#/components/responses/Message'}
        '404': {$ref: '#/components/responses/NotFound'}
  /student/exams:
    get:
      tags: [Student]
      summary: List only the current student's assignments in the active tenant
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Collection'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /student/exams/{assigned}:
    get:
      tags: [Student]
      summary: Read an owned assignment, instructions, policies, and accommodations
      parameters:
        - {$ref: '#/components/parameters/AppVersion'}
        - {$ref: '#/components/parameters/ApiVersion'}
        - {name: assigned, in: path, required: true, schema: {type: integer}}
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
  /student/exams/{assigned}/attempts:
    post:
      tags: [Student]
      summary: Start or resume an eligible owned assignment through ExamAttemptService
      parameters:
        - {$ref: '#/components/parameters/AppVersion'}
        - {$ref: '#/components/parameters/ApiVersion'}
        - {name: assigned, in: path, required: true, schema: {type: integer}}
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                access_code: {type: [string, 'null'], writeOnly: true}
      responses:
        '200':
          description: Authoritative attempt state without correct answers
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: {type: string}
                  data: {$ref: '#/components/schemas/AttemptState'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '422': {$ref: '#/components/responses/ValidationError'}
  /student/attempts/{attempt}:
    get:
      tags: [Student]
      summary: Reload the authoritative state of an owned attempt
      parameters: *attemptHeaders
      responses:
        '200':
          description: Attempt state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: {$ref: '#/components/schemas/AttemptState'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
  /student/attempts/{attempt}/sync:
    post:
      tags: [Student]
      summary: Synchronize an idempotent, versioned offline answer packet
      parameters: *attemptHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/AnswerMutation'}
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '409': {description: Idempotency key conflict}
        '422': {$ref: '#/components/responses/ValidationError'}
  /student/attempts/{attempt}/heartbeat:
    post:
      tags: [Student]
      summary: Update attempt connectivity and session evidence
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
  /student/attempts/{attempt}/integrity-events:
    post:
      tags: [Student]
      summary: Record configured attempt integrity events for human review
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '422': {$ref: '#/components/responses/ValidationError'}
  /student/attempts/{attempt}/camera-evidence:
    post:
      tags: [Student]
      summary: Upload authorized camera evidence when enterprise policy requires it
      parameters: *attemptHeaders
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [snapshot]
              properties:
                snapshot: {type: string, format: binary}
                client_time: {type: string, format: date-time}
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /student/attempts/{attempt}/submit:
    post:
      tags: [Student]
      summary: Submit an owned attempt through the authoritative domain service
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '422': {$ref: '#/components/responses/ValidationError'}
  /student/results:
    get:
      tags: [Student]
      summary: List only the current student's published results
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Collection'}
  /student/results/{result}/recheck:
    post:
      tags: [Student]
      summary: Request a recheck of an owned published result
      parameters:
        - {$ref: '#/components/parameters/AppVersion'}
        - {$ref: '#/components/parameters/ApiVersion'}
        - {name: result, in: path, required: true, schema: {type: integer}}
      responses:
        '200': {$ref: '#/components/responses/Item'}
  /student/documents:
    get:
      tags: [Student]
      summary: List issued result documents owned by the current student
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Collection'}
  /parent/children:
    get:
      tags: [Parent]
      summary: List students explicitly linked to the parent in the active tenant
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Collection'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /parent/children/{student}:
    get:
      tags: [Parent]
      summary: Load a linked child's assessment summary and published outcomes
      parameters:
        - {$ref: '#/components/parameters/AppVersion'}
        - {$ref: '#/components/parameters/ApiVersion'}
        - {name: student, in: path, required: true, schema: {type: integer}}
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '404': {$ref: '#/components/responses/NotFound'}
  /staff/queue:
    get:
      tags: [Staff]
      summary: Load role-scoped active, integrity, marking, and approval queues
      parameters: *compatibilityHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /staff/attempts/{attempt}:
    get:
      tags: [Staff]
      summary: Load an authorized attempt for focused review
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '404': {$ref: '#/components/responses/NotFound'}
  /staff/attempts/{attempt}/risk-review:
    patch:
      tags: [Staff]
      summary: Record an authorized integrity decision and audit context
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /staff/attempts/{attempt}/grade:
    post:
      tags: [Staff]
      summary: Save authorized manual grades through ExamManualGradingService
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /staff/attempts/{attempt}/approve-result:
    post:
      tags: [Staff]
      summary: Approve a result through ResultCalculationService when current state permits
      parameters: *attemptHeaders
      responses:
        '200': {$ref: '#/components/responses/Item'}
        '403': {$ref: '#/components/responses/Forbidden'}
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
  parameters:
    AppVersion:
      name: X-Ovion-App-Version
      in: header
      required: true
      schema: {type: string, pattern: '^\\d+\\.\\d+\\.\\d+'}
    ApiVersion:
      name: X-Ovion-API-Version
      in: header
      required: true
      schema: {type: string, enum: ['1']}
    AttemptId:
      name: attempt
      in: path
      required: true
      schema: {type: integer}
  schemas:
    Discovery:
      type: object
      required: [product, installation, api, brand, links]
      properties:
        product: {type: object, additionalProperties: true}
        installation: {type: object, additionalProperties: true}
        api: {type: object, additionalProperties: true}
        brand: {$ref: '#/components/schemas/Brand'}
        links: {type: object, additionalProperties: true}
    Brand:
      type: object
      properties:
        name: {type: string}
        primary_color: {type: string}
        secondary_color: {type: string}
        logo_url: {type: [string, 'null'], format: uri}
    DeviceRegistration:
      type: object
      required: [device_uuid, platform]
      properties:
        device_uuid: {type: string, maxLength: 120}
        platform: {type: string, enum: [android, ios]}
        device_name: {type: [string, 'null']}
        os_version: {type: [string, 'null']}
        build_number: {type: [string, 'null']}
        locale: {type: [string, 'null']}
        timezone: {type: [string, 'null']}
    TokenPair:
      type: object
      properties:
        token_type: {type: string, const: Bearer}
        access_token: {type: string, writeOnly: true}
        refresh_token: {type: string, writeOnly: true}
        expires_at: {type: string, format: date-time}
        refresh_expires_at: {type: string, format: date-time}
        session_id: {type: integer}
        device_id: {type: string, format: uuid}
        scopes: {type: array, items: {type: string}}
    Bootstrap:
      type: object
      properties:
        user: {type: object, additionalProperties: true}
        institute: {type: object, additionalProperties: true}
        brand: {$ref: '#/components/schemas/Brand'}
        scopes: {type: array, items: {type: string}}
        capabilities: {type: object, additionalProperties: {type: boolean}}
        offline_policy: {type: object, additionalProperties: true}
        compatibility: {type: object, additionalProperties: true}
    AttemptState:
      type: object
      description: Student-safe attempt state. Correct answers and scoring keys are never included.
      properties:
        attempt: {type: object, additionalProperties: true}
        exam: {type: object, additionalProperties: true}
        schedule: {type: object, additionalProperties: true}
        sections: {type: array, items: {type: object, additionalProperties: true}}
        questions: {type: array, items: {type: object, additionalProperties: true}}
        answers: {type: array, items: {type: object, additionalProperties: true}}
        palette: {type: array, items: {type: object, additionalProperties: true}}
        risk: {type: object, additionalProperties: true}
    AnswerMutation:
      type: object
      required: [mutation_uuid, idempotency_key, answers]
      properties:
        mutation_uuid: {type: string, format: uuid}
        idempotency_key: {type: string, maxLength: 120}
        answers:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: object
            required: [exam_question_id, sync_version, client_uuid]
            properties:
              exam_question_id: {type: integer}
              answer_payload: {}
              text_answer: {type: [string, 'null'], maxLength: 20000}
              marked_for_review: {type: boolean}
              skipped: {type: boolean}
              sync_version: {type: integer, minimum: 0}
              client_uuid: {type: string, maxLength: 80}
    Problem:
      type: object
      required: [message]
      properties:
        message: {type: string}
        code: {type: string}
        errors: {type: object, additionalProperties: {type: array, items: {type: string}}}
  responses:
    SessionResponse:
      description: Authenticated session or challenge response
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  status: {type: string, enum: [authenticated, challenge_required]}
                  session: {$ref: '#/components/schemas/TokenPair'}
    Message:
      description: Operation completed
      content:
        application/json:
          schema: {type: object, properties: {message: {type: string}, data: {}}}
    Item:
      description: Single response item
      content:
        application/json:
          schema: {type: object, properties: {data: {type: object, additionalProperties: true}, message: {type: string}}}
    Collection:
      description: Response collection
      content:
        application/json:
          schema: {type: object, properties: {data: {type: array, items: {}}}}
    Unauthorized:
      description: Invalid, expired, rotated, revoked, or otherwise unusable session
      content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
    Forbidden:
      description: Current user, role, scope, ownership, or state does not permit the action
      content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
    NotFound:
      description: Resource does not exist in the visible tenant boundary
      content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
    ValidationError:
      description: Request or current assessment state failed validation
      content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
    UpgradeRequired:
      description: Application is below the server's minimum supported version
      content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
