openapi: 3.0.3
info:
  title: ImpAcc User Profile API
  description: |
    Authenticate users against the ImpAcc user database and read their
    profile. Uses Laravel Sanctum bearer tokens.
  version: "1.0.0"
  contact:
    name: ImpAcc API Team
servers:
  - url: https://uat-usr.impacc.work/api
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: Login / logout (token lifecycle) — v1
  - name: Profile
    description: Read the authenticated user's profile — v1
  - name: v2 (hardened)
    description: >
      Opt-in hardened endpoints under /api/v2. Same payload contract as v1,
      plus: access tokens expire (8h; response includes expires_in), strict
      13-digit citizen_id validation, rate limiting (429), single-use SSO
      mTokens with a 120s TTL, and server-side audit logging. v1 is unchanged.
  - name: v3 (encrypted)
    description: >
      Opt-in endpoints under /api/v3 with everything in v2 PLUS payload
      encryption. The caller registers an RSA public key (-> client_id) and
      sends X-Client-Id per request; every SUCCESSFUL response is returned as
      a hybrid-encrypted envelope (RSA-OAEP-wrapped AES-256-GCM). Decrypt the
      envelope with the client private key to obtain the normal v1/v2 payload.
      Error responses stay plain JSON.

paths:
  /login:
    post:
      tags: [Auth]
      summary: Exchange credentials for a bearer token
      security: []   # public endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            examples:
              default:
                value:
                  citizen_id: "1234567890123"
                  password: "user-secret"
                  device_name: "partner-app-web"
      responses:
        '200':
          description: Login succeeded; token returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '422':
          description: Validation error or wrong credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '429':
          description: Too many requests.

  /sso/exchange:
    post:
      tags: [Auth]
      summary: Exchange a one-time portal mToken for a bearer token (SSO)
      description: |
        Alternative to /login for apps launched from the imPACC portal. The
        portal mints a single-use mToken on the user record and redirects the
        browser with it; post it here to obtain a bearer token and profile
        without handling credentials. The mToken is cleared on success
        (single-use).
      security: []   # public endpoint; the mToken is the credential
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoExchangeRequest'
            examples:
              default:
                value:
                  mToken: "0f3a...64-char-token...d9"
                  device_name: "sso-web"
      responses:
        '200':
          description: Exchange succeeded; token returned (same shape as /login).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '422':
          description: Unknown, already-used, or expired mToken, or inactive account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '429':
          description: Too many requests.

  /logout:
    post:
      tags: [Auth]
      summary: Revoke the current bearer token
      responses:
        '200':
          description: Token revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Logged out"
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /profile:
    get:
      tags: [Profile]
      summary: Get the authenticated user's profile
      responses:
        '200':
          description: Profile payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: '#/components/schemas/UserProfile'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /permissions:
    get:
      tags: [Profile]
      summary: Get the authenticated user's effective applications/menus/sections
      description: |
        Effective access resolved as
        (group grants ∪ direct user grants) ∖ user blocks, across the
        application → menu → section tiers. Groups are the union of
        workgroup-derived (workgroup_groups via users.workgroup_id) and
        direct (user_groups) memberships. The same object is also embedded
        as `user.permissions` in the login and profile responses.
      responses:
        '200':
          description: Effective permission tree.
          content:
            application/json:
              schema:
                type: object
                properties:
                  permissions:
                    $ref: '#/components/schemas/Permissions'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  # ---------------------------------------------------------------------------
  # v2 — hardened (opt-in). Same shapes as v1 plus expires_in, rate limits (429),
  # token/mToken expiry, strict validation, and audit logging.
  # ---------------------------------------------------------------------------
  /v2/login:
    post:
      tags: [v2 (hardened)]
      summary: "[v2] Exchange credentials for an expiring bearer token"
      security: []
      description: |
        Like /login, but: citizen_id must be exactly 13 digits, the issued
        token expires in 8h (response adds expires_in), the endpoint is rate
        limited to 5 requests/min/IP, and every attempt is audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login succeeded; expiring token returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponseV2'
        '422':
          description: Validation error, wrong credentials, or inactive account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '429':
          description: Rate limit exceeded (5/min/IP).

  /v2/sso/exchange:
    post:
      tags: [v2 (hardened)]
      summary: "[v2] Exchange a one-time portal mToken (with TTL) for an expiring token"
      security: []
      description: |
        Like /sso/exchange, but the mToken is rejected if older than 120s
        (checked against the portal's launch log), the issued token expires in
        8h (response adds expires_in), rate limited to 10/min/IP, and audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoExchangeRequest'
      responses:
        '200':
          description: Exchange succeeded; expiring token returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponseV2'
        '422':
          description: Unknown, used, or expired mToken, or inactive account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '429':
          description: Rate limit exceeded (10/min/IP).

  /v2/profile:
    get:
      tags: [v2 (hardened)]
      summary: "[v2] Get the authenticated user's profile"
      responses:
        '200':
          description: Profile payload (identical shape to v1).
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: '#/components/schemas/UserProfile'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v2/permissions:
    get:
      tags: [v2 (hardened)]
      summary: "[v2] Get effective applications/menus/sections"
      responses:
        '200':
          description: Effective permission tree (identical shape to v1).
          content:
            application/json:
              schema:
                type: object
                properties:
                  permissions:
                    $ref: '#/components/schemas/Permissions'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v2/logout:
    post:
      tags: [v2 (hardened)]
      summary: "[v2] Revoke the current bearer token"
      responses:
        '200':
          description: Token revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Logged out"
        '401':
          $ref: '#/components/responses/Unauthenticated'

  # ---------------------------------------------------------------------------
  # v3 — v2 + payload encryption. Register a public key, send X-Client-Id, and
  # successful responses arrive as an EncryptedEnvelope (decrypt client-side).
  # ---------------------------------------------------------------------------
  /v3/clients/register:
    post:
      tags: [v3 (encrypted)]
      summary: "[v3] Register a client RSA public key → client_id"
      security: []
      description: |
        Submit an RSA public key (PEM); receive a client_id to send as
        X-Client-Id on subsequent v3 calls. Response is plain JSON (bootstrap).
        NOTE: restrict this endpoint to admins/allowlist in production.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClientRegisterRequest'
      responses:
        '200':
          description: Client registered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClientRegisterResponse'
        '422':
          description: Missing/invalid public key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

  /v3/login:
    post:
      tags: [v3 (encrypted)]
      summary: "[v3] Login; encrypted token response"
      security: []
      parameters: [{ $ref: '#/components/parameters/ClientId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: "Success — encrypted envelope; decrypts to a v2 token response (token/expires_in/user)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EncryptedEnvelope'
        '400':
          description: Missing or unknown X-Client-Id.
        '422':
          description: Validation error / bad credentials / inactive (plain JSON).
        '429':
          description: Rate limit exceeded.

  /v3/sso/exchange:
    post:
      tags: [v3 (encrypted)]
      summary: "[v3] SSO exchange; encrypted token response"
      security: []
      parameters: [{ $ref: '#/components/parameters/ClientId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoExchangeRequest'
      responses:
        '200':
          description: "Success — encrypted envelope; decrypts to a v2 token response."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EncryptedEnvelope'
        '400':
          description: Missing or unknown X-Client-Id.
        '422':
          description: Unknown/used/expired mToken (plain JSON).
        '429':
          description: Rate limit exceeded.

  /v3/profile:
    get:
      tags: [v3 (encrypted)]
      summary: "[v3] Profile; encrypted response"
      parameters: [{ $ref: '#/components/parameters/ClientId' }]
      responses:
        '200':
          description: "Encrypted envelope; decrypts to { user: ... } (v1 shape)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EncryptedEnvelope'
        '400':
          description: Missing or unknown X-Client-Id.
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v3/permissions:
    get:
      tags: [v3 (encrypted)]
      summary: "[v3] Permissions; encrypted response"
      parameters: [{ $ref: '#/components/parameters/ClientId' }]
      responses:
        '200':
          description: "Encrypted envelope; decrypts to { permissions: ... } (v1 shape)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EncryptedEnvelope'
        '400':
          description: Missing or unknown X-Client-Id.
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v3/logout:
    post:
      tags: [v3 (encrypted)]
      summary: "[v3] Logout; encrypted response"
      parameters: [{ $ref: '#/components/parameters/ClientId' }]
      responses:
        '200':
          description: "Encrypted envelope; decrypts to { message: 'Logged out' }."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EncryptedEnvelope'
        '400':
          description: Missing or unknown X-Client-Id.
        '401':
          $ref: '#/components/responses/Unauthenticated'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sanctum

  parameters:
    ClientId:
      name: X-Client-Id
      in: header
      required: true
      description: Registered client id (from /v3/clients/register). Selects the public key used to encrypt the response.
      schema: { type: string, example: "cli_94efd22f20e50f662aee4578" }

  responses:
    Unauthenticated:
      description: Missing, invalid, or revoked token.
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                type: string
                example: "Unauthenticated."

  schemas:
    LoginRequest:
      type: object
      required: [citizen_id, password]
      properties:
        citizen_id:
          type: string
          description: 13-digit citizen ID.
          example: "1234567890123"
        password:
          type: string
          format: password
          example: "user-secret"
        device_name:
          type: string
          maxLength: 100
          description: Label stored with the issued token.
          example: "partner-app-web"

    SsoExchangeRequest:
      type: object
      required: [mToken]
      properties:
        mToken:
          type: string
          description: One-time token minted by the imPACC portal. Single-use.
          example: "0f3a...64-char-token...d9"
        device_name:
          type: string
          maxLength: 100
          description: Label stored with the issued token.
          example: "sso-web"

    ClientRegisterRequest:
      type: object
      required: [name, public_key]
      properties:
        name:       { type: string, maxLength: 120, example: "partner-app" }
        public_key: { type: string, description: "RSA public key, PEM format.", example: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" }

    ClientRegisterResponse:
      type: object
      properties:
        client_id: { type: string, example: "cli_94efd22f20e50f662aee4578" }
        name:      { type: string, example: "partner-app" }

    EncryptedEnvelope:
      type: object
      description: >
        Hybrid-encrypted v3 response. Decrypt: RSA-OAEP-decrypt `key` with the
        client private key to get the AES-256 key, then AES-256-GCM-decrypt
        `ciphertext` using `iv` and `tag`. The plaintext is the normal v1/v2 JSON.
      properties:
        encrypted:  { type: boolean, example: true }
        alg:        { type: string, example: "RSA-OAEP+A256GCM" }
        key:        { type: string, description: "base64(RSA-OAEP(aes_key))" }
        iv:         { type: string, description: "base64(12-byte GCM nonce)" }
        tag:        { type: string, description: "base64(16-byte GCM auth tag)" }
        ciphertext: { type: string, description: "base64(AES-256-GCM(body))" }

    TokenResponseV2:
      type: object
      description: v2 token response — same as LoginResponse plus expires_in.
      properties:
        token:
          type: string
          example: "42|nQlGT1UXax4Iu...redacted..."
        token_type:
          type: string
          example: "Bearer"
        expires_in:
          type: integer
          description: Token lifetime in seconds (8h).
          example: 28800
        user:
          $ref: '#/components/schemas/UserProfile'

    LoginResponse:
      type: object
      properties:
        token:
          type: string
          description: "Bearer token. Send as `Authorization: Bearer <token>`."
          example: "12|H7c0gQ9b3l...redacted...XyZ"
        token_type:
          type: string
          example: "Bearer"
        user:
          $ref: '#/components/schemas/UserProfile'

    UserProfile:
      type: object
      properties:
        id:                { type: integer, example: 1234 }
        citizen_id:        { type: string, example: "1234567890123" }
        title:             { type: string, nullable: true, example: "นาย" }
        firstname:         { type: string, nullable: true, example: "สมชาย" }
        lastname:          { type: string, nullable: true, example: "ใจดี" }
        title_english:     { type: string, nullable: true, example: "Mr." }
        firstname_english: { type: string, nullable: true, example: "Somchai" }
        lastname_english:  { type: string, nullable: true, example: "Jaidee" }
        email:             { type: string, nullable: true, format: email, example: "somchai@example.com" }
        mobile:            { type: string, nullable: true, example: "0812345678" }
        born_date:         { type: string, nullable: true, format: date, example: "1990-05-12" }
        workgroup:         { type: string, nullable: true, example: "IT" }
        workgroup_id:      { type: integer, nullable: true, example: 39, description: "FK to workgroups; drives permission resolution." }
        division_id:       { type: integer, nullable: true, example: 2, description: "FK to divisions." }
        organization:      { type: string, nullable: true, example: "Impacc" }
        role_type1:        { type: string, nullable: true, example: "officer" }
        role_type2:        { type: string, nullable: true }
        role_type3:        { type: string, nullable: true }
        status:            { type: string, nullable: true, example: "1", description: "\"1\" = active." }
        roles:
          type: array
          items: { type: string }
          example: ["user"]
        last_login:
          type: string
          nullable: true
          format: date-time
          example: "2026-05-19T08:42:11.000000Z"
        current_profile:
          nullable: true
          allOf:
            - $ref: '#/components/schemas/Profile'
          description: "The user's currently active OU profile (falls back to the default). Null if the user has no rows in user_profiles."
        profiles:
          type: array
          description: "All OU / special-mission assignments. Ordered by is_active DESC, is_default DESC, id."
          items:
            $ref: '#/components/schemas/Profile'
        permissions:
          $ref: '#/components/schemas/Permissions'

    Profile:
      type: object
      description: "One organizational-unit assignment for the user (home, secondary, or special mission)."
      properties:
        id:                   { type: integer, example: 1 }
        label:                { type: string, nullable: true, example: "ต้นสังกัด" }
        kind:
          type: string
          enum: [home, secondary, mission]
          description: "Convenience classifier derived from is_default / is_temporary."
          example: "home"
        dept1:                { type: string, nullable: true, example: "สำนักงาน ป.ป.ท." }
        dept2:                { type: string, nullable: true, example: "สำนักงาน ป.ป.ท." }
        dept3:                { type: string, nullable: true }
        officer_type_id:      { type: integer, nullable: true, example: 4 }
        officer_type_name:    { type: string, nullable: true, example: "ทั่วไป" }
        position_type_id:     { type: integer, nullable: true, example: 3 }
        position_type_name:   { type: string, nullable: true, example: "วิชาการ" }
        position:             { type: string, nullable: true, example: "นักวิชาการ" }
        description:          { type: string, nullable: true }
        level:                { type: string, nullable: true, example: "ปฏิบัติการ" }
        management_position:  { type: string, nullable: true }
        is_default:           { type: boolean, example: true }
        is_active:            { type: boolean, example: true }
        is_temporary:         { type: boolean, example: false }
        mission_title:        { type: string, nullable: true, example: "สืบสวน" }
        mission_note:         { type: string, nullable: true }
        mission_start_date:   { type: string, nullable: true, format: date, example: "2026-01-01" }
        mission_end_date:     { type: string, nullable: true, format: date, example: "2026-06-30" }
        mission_order_no:     { type: string, nullable: true, example: "123/2569" }
        mission_order_file:   { type: string, nullable: true, example: "orders/2569-123.pdf" }
        status:               { type: string, nullable: true, example: "approved" }
        approved_at:          { type: string, nullable: true, format: date-time }

    Permissions:
      type: object
      description: "The user's effective applications, menus and sections."
      properties:
        group_ids:
          type: array
          description: "Groups the user belongs to (workgroup-derived ∪ direct)."
          items: { type: integer }
          example: [1, 2, 3, 22, 23]
        applications:
          type: array
          description: "Effective applications, ordered by sequence then id."
          items:
            $ref: '#/components/schemas/PermissionApp'

    PermissionApp:
      type: object
      properties:
        id:     { type: integer, example: 22 }
        app_id: { type: string, nullable: true, example: "11-22" }
        name:   { type: string, nullable: true, example: "ระบบDIDC" }
        link:   { type: string, nullable: true, example: "/miniapp/didc" }
        menus:
          type: array
          description: "Effective menus under this application (empty if none)."
          items:
            $ref: '#/components/schemas/PermissionMenu'

    PermissionMenu:
      type: object
      properties:
        id:     { type: integer, example: 81 }
        name:   { type: string, nullable: true, example: "จัดการ Dashboard" }
        path:   { type: string, nullable: true, example: "/dashboard-management" }
        level:  { type: integer, nullable: true, example: 2 }
        parent: { type: integer, nullable: true, example: 75 }
        sections:
          type: array
          description: "Effective sections under this menu (empty until section data exists)."
          items:
            $ref: '#/components/schemas/PermissionSection'

    PermissionSection:
      type: object
      properties:
        id:   { type: integer, example: 5 }
        name: { type: string, nullable: true, example: "ภาพรวม" }

    ValidationError:
      type: object
      properties:
        message:
          type: string
          example: "The provided credentials are incorrect."
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }
          example:
            citizen_id: ["The provided credentials are incorrect."]
