openapi: 3.0.3
info:
  title: TravelQ ID API – ID Định Danh Du Lịch
  version: 1.2.0
  description: |
    API cấp và sử dụng **TravelQ ID** (định dạng `TQ` + 9 chữ số, VD `TQ482019379`, số cuối là số kiểm tra Luhn; ID cũ dạng `TQ-YYYY-XXXXXXXC` vẫn được chấp nhận).
    - **Ứng dụng TravelQ** (đối tượng chính): đăng nhập bằng JSON, nhận `accessToken` (15 phút) + `refreshToken` (30 ngày, xoay vòng), gửi `Authorization: Bearer <accessToken>`.
    - **Trang web TravelQ ID**: cookie phiên HttpOnly + header `X-CSRF-Token` (dùng nội bộ).
    - **Dịch vụ trong hệ sinh thái TravelQ** (khách sạn, nhà hàng, vé…): gọi từ máy chủ với `X-API-Key` do quản trị cấp – mỗi trang một key.
    - **Ứng dụng bất kỳ – "Đăng nhập bằng TravelQ ID"**: OAuth 2.0 / OpenID Connect, code + PKCE (S256). Discovery `/.well-known/openid-configuration`, `id_token` RS256 với `sub` = mã tài khoản lõi.
    - **Kết nối App TravelQ (máy chủ) / dữ liệu nội bộ** (backend App TravelQ, kho dữ liệu của TravelQ): `/api/v1/data/*`, máy chủ ↔ máy chủ, khoá dữ liệu `tqd_…`/`tqds_…` (HTTP Basic) hoặc token `tqdt_…` từ `POST /data/token` (client_credentials, 15 phút). Đồng bộ theo `cursor`/`updated_since`, xuất JSON/NDJSON/CSV, ghi/đọc giao dịch, nhật ký sự kiện + webhook (`traveller.created`, `id.issued`, `profile.updated`, `id.locked`, `id.unlocked`). Mặc định chỉ trả số giấy tờ dạng che; số đầy đủ chỉ khi máy chủ bật `DATA_ENCRYPTION_KEY` và khoá có `read:documents_full`.
    - **Webhook** ký HMAC-SHA256: header `x-tqid-signature: t=<unix>,v1=<hex(HMAC(secret, t + "." + body))>`; sự kiện `id.issued`, `profile.updated`, `id.locked`, `id.unlocked`, `ping`. Payload chỉ gồm `{sub, tqid, status, changed?}`.
    Mọi lỗi có dạng `{"error":{"code","message","errors?"}}`. Bản thử nghiệm: OTP mô phỏng, dữ liệu demo.
servers:
  - url: /api/v1
tags:
  - { name: Chung }
  - { name: Xác thực, description: Đăng ký / đăng nhập / làm mới token }
  - { name: Bộ lập ID, description: 4 bước khai báo + hoàn tất để nhận ID }
  - { name: Hồ sơ, description: Hồ sơ, thẻ ID, giao dịch, chia sẻ dữ liệu, thiết bị }
  - { name: Lấy lại tài khoản, description: "d22: giấy tờ đã có TravelQ ID nhưng không vào được tài khoản – Đường A (OTP tới SĐT/email đã lưu) hoặc Đường B (ảnh + số mới → nhân viên duyệt)" }
  - { name: Dịch vụ, description: API cho máy chủ dịch vụ (X-API-Key) }
  - { name: OIDC, description: Đăng nhập bằng TravelQ ID (OAuth 2.0 / OpenID Connect) – các đường dẫn nằm ở gốc tên miền, không có /api/v1 }
  - { name: Dữ liệu nội bộ, description: "API máy chủ ↔ máy chủ cho backend App TravelQ và hệ thống dữ liệu nội bộ (khoá tạo ở /admin → Kết nối App TravelQ). Giao diện app dùng token người dùng + /embed; backend ghép người dùng app ↔ sub, lưu travelId khi có id.issued, đẩy đơn qua /data/travellers/{tqid}/transactions. Không gọi từ trình duyệt: có header Origin → 403 browser_not_allowed." }
  - { name: Quản trị, description: Đăng ký ứng dụng, webhook (cookie quản trị + CSRF) }
components:
  securitySchemes:
    bearer: { type: http, scheme: bearer, description: "accessToken dạng tqa_…" }
    apiKey: { type: apiKey, in: header, name: X-API-Key, description: "tqk_<id>_<bí mật> – chỉ dùng phía máy chủ" }
    oauthBearer: { type: http, scheme: bearer, description: "access token OIDC dạng tqo_…" }
    oidc: { type: openIdConnect, openIdConnectUrl: /.well-known/openid-configuration }
    dataBasic: { type: http, scheme: basic, description: "Khoá dữ liệu nội bộ: username = client_id `tqd_…`, password = client_secret `tqds_…`" }
    dataBearer: { type: http, scheme: bearer, description: "Token `tqdt_…` từ POST /data/token (15 phút)" }
  parameters:
    tqid: { name: tqid, in: path, required: true, schema: { type: string, example: TQ482019379 }, description: "Chấp nhận có/không dấu gạch, chữ thường; sai ký tự kiểm tra → 400 invalid_tqid" }
  schemas:
    DocAiProviderRequest:
      description: |
        d22 – HỢP ĐỒNG NHÀ CUNG CẤP AI ĐỌC GIẤY TỜ (máy chủ TravelQ ID → dịch vụ AI của bạn). TravelQ ID gửi `POST <DOCAI_URL>` với header
        `authorization: Bearer <DOCAI_API_KEY>` (nếu có), `x-request-id: <requestId>`, `user-agent: TravelQ-ID-DocAI/1`.
        Mặc định `content-type: application/json` (thân như dưới); `DOCAI_FORMAT=multipart` → `multipart/form-data` với các trường `requestId`, `docType`, `lang` và tệp `image` (image/jpeg).
        Chỉ gọi khi DOCAI_ENABLED=1, SAU khi khách tick đồng ý (d21) và SAU khi đọc trên thiết bị thất bại. Không theo chuyển hướng (redirect → lỗi). Phản hồi > 64 KB bị bỏ.
      type: object
      required: [requestId, docType, images]
      properties:
        requestId: { type: string, format: uuid, description: "Mã yêu cầu, trùng header x-request-id – dùng để đối chiếu nhật ký hai bên" }
        docType: { type: string, enum: [cccd_front, cccd_back, passport] }
        lang: { type: string, example: vi, description: "Ngôn ngữ giao diện của khách (vi, en, ko, zh, ja, ru, fr, de)" }
        images:
          type: array
          minItems: 1
          items:
            type: object
            required: [side, mime, data]
            properties:
              side: { type: string, enum: [front, back, passport] }
              mime: { type: string, enum: [image/jpeg] }
              data: { type: string, format: byte, description: "base64 (không có tiền tố data:)" }
      example: { requestId: 3f1c2a9e-0b7d-4c55-9a51-2b8f4f0e6d11, docType: cccd_front, lang: vi, images: [{ side: front, mime: image/jpeg, data: "/9j/4AAQSkZJRg…" }] }
    DocAiProviderResponse:
      description: |
        Phản hồi JSON mong đợi (HTTP 200). Mọi trường đều không bắt buộc; trường thiếu / sai định dạng / có độ tin cậy < DOCAI_MIN_CONFIDENCE (mặc định 0.8) bị bỏ qua.
        Trường không kèm `confidence` bị bỏ (trừ khi DOCAI_MIN_CONFIDENCE=0). TravelQ ID chỉ điền những ô khách CHƯA nhập (hiện tại: ngày sinh, giới tính);
        tên và năm sinh chỉ dùng để gắn cờ "lệch" cho nhân viên xem – không bao giờ ghi đè dữ liệu khách đã gõ.
      type: object
      properties:
        ok: { type: boolean, description: "false → coi như lỗi, đọc error.code" }
        fields:
          type: object
          properties:
            fullName: { type: string, maxLength: 80 }
            dob: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$', description: YYYY-MM-DD }
            sex: { type: string, enum: [M, F, X] }
            idNumber: { type: string, description: "Số CCCD 12 chữ số (docType cccd_*)" }
            address: { type: string, maxLength: 160 }
            nationality: { type: string, description: "Mã ISO 3166 alpha-3 hoặc alpha-2, VD VNM" }
            passportNumber: { type: string, description: "6–9 chữ/số (docType passport)" }
            expiry: { type: string, description: YYYY-MM-DD }
        confidence:
          type: object
          description: "Độ tin cậy 0..1 theo từng trường, cùng tên khoá với fields"
          additionalProperties: { type: number, minimum: 0, maximum: 1 }
      example: { ok: true, fields: { fullName: Nguyễn Văn Mẫu, dob: '1990-01-01', sex: M, idNumber: '000000000202', address: '1 Đường Mẫu, Phường Mẫu', nationality: VNM }, confidence: { fullName: 0.97, dob: 0.95, sex: 0.99, idNumber: 0.96, address: 0.85, nationality: 0.99 } }
    DocAiProviderError:
      description: |
        Lỗi từ nhà cung cấp: HTTP 4xx/5xx kèm `{"ok":false,"error":{"code","message"}}` (hoặc 200 với ok=false). Mã khuyến nghị:
        `unauthorized` (401, sai khoá), `unreadable` (422, ảnh không đọc được), `unsupported_document` (422), `too_large` (413), `rate_limited` (429), `internal` (500).
        Phía TravelQ ID còn tự sinh: `timeout` (quá DOCAI_TIMEOUT_MS, mặc định 8000 ms, 1000–30000), `network`, `bad_response` (không phải JSON hợp lệ / quá 64 KB),
        `no_fields` (không còn trường nào đạt ngưỡng), `http_<status>` (lỗi không có error.code).
        MỌI lỗi đều IM LẶNG với khách: không hiện thông báo, khách tiếp tục tự nhập / hồ sơ vẫn được cấp ID; chỉ ghi nhật ký `kyc.docai_read` (không ảnh, không giá trị, không khoá).
      type: object
      properties:
        ok: { type: boolean, enum: [false] }
        error: { type: object, properties: { code: { type: string }, message: { type: string } } }
      example: { ok: false, error: { code: unreadable, message: Image is not readable } }
    RecoverChannel:
      type: object
      properties: { channel: { type: string, enum: [sms, email] }, masked: { type: string, example: '09•••678' } }
    DataDocument:
      type: object
      properties:
        type: { type: string, enum: [cccd, passport] }
        masked: { type: string, example: "079•••••345" }
        country: { type: string, description: Chỉ với hộ chiếu }
        expiry: { type: string, description: Chỉ với hộ chiếu }
        number: { type: string, nullable: true, description: "Chỉ có khi khoá có read:documents_full" }
        numberStatus: { type: string, enum: [ok, encryption_disabled, not_stored, decrypt_failed], description: "Chỉ có khi khoá có read:documents_full" }
    FaceStatus:
      type: object
      description: "d15: kết quả nhận diện khuôn mặt (điểm MÔ PHỎNG – method=simulated; chưa dùng nhà cung cấp eKYC thật). Ảnh khuôn mặt bị xoá ngay sau khi so khớp; không lưu mẫu sinh trắc học."
      properties:
        status: { type: string, enum: [not_done, failed, face_verified, manual_review] }
        verified: { type: boolean }
        at: { type: string, format: date-time }
        liveness: { type: number, example: 0.951, description: "Ngưỡng đạt ≥ 0.90" }
        match: { type: number, example: 0.874, description: "So với ảnh chân dung trên giấy tờ; ngưỡng đạt ≥ 0.80" }
        attempts: { type: integer, example: 1, description: "Tối đa 2 lượt; hết lượt → manual_review" }
        method: { type: string, example: simulated }
    Verification:
      type: object
      description: "d15: chỉ trả khi khoá có read:documents_full. Chỉ siêu dữ liệu – API dữ liệu không trả ảnh giấy tờ."
      properties:
        face: { $ref: '#/components/schemas/FaceStatus' }
        documentImages: { type: array, items: { type: object, properties: { side: { type: string, enum: [front, back, passport] }, capturedAt: { type: string, format: date-time }, stored: { type: boolean, description: "true = ảnh đã lưu mã hoá AES-256-GCM (DATA_ENCRYPTION_KEY); false = máy chủ chưa bật khoá nên không lưu ảnh" } } } }
        biometricConsent: { type: object, nullable: true, properties: { version: { type: string, example: 2026-10-bio-v1 }, at: { type: string, format: date-time } } }
    KycState:
      type: object
      properties:
        docType: { type: string, enum: [cccd, passport] }
        sides: { type: array, items: { type: string }, example: [front, back] }
        docImages: { type: object, additionalProperties: { type: object, properties: { at: { type: string }, stored: { type: boolean } } } }
        documentDone: { type: boolean }
        face: { allOf: [{ $ref: '#/components/schemas/FaceStatus' }], nullable: true }
        faceDone: { type: boolean, description: "face_verified hoặc manual_review" }
        consent: { type: object, nullable: true }
        consentVersion: { type: string }
        thresholds: { type: object, example: { liveness: 0.9, match: 0.8, maxAttempts: 2 } }
        storage: { type: object, example: { documentImages: encrypted, selfie: deleted_after_match } }
    DataTraveller:
      type: object
      description: "Hồ sơ du khách cho hệ thống nội bộ (read:travellers): 7 trường + loại khách, Travel ID, ngày cấp, trạng thái"
      properties:
        travelId: { type: string, example: TQ482019379 }
        sub: { type: string, example: u_Ab12Cd34 }
        kind: { type: string, enum: [vn, intl] }
        kindLabel: { type: string, enum: [Công dân Việt Nam, Người nước ngoài] }
        status: { type: string, enum: [active, locked] }
        issuedAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        lockedAt: { type: string, format: date-time, nullable: true }
        name: { type: string, example: Nguyễn Văn An }
        birthYear: { type: integer, example: 1990 }
        document: { $ref: '#/components/schemas/DataDocument' }
        street: { type: string }
        phone: { type: string, example: '0901234567' }
        phoneE164: { type: string, example: '+84901234567' }
        prov: { type: string, example: Gia Lai }
        country: { type: string, example: VN }
        countryName: { type: string, example: Việt Nam }
        email: { type: string }
        dob: { type: string }
        gender: { type: string }
        nationality: { type: string }
        ward: { type: string }
        addr: { type: string }
        consent: { type: object, nullable: true, properties: { version: { type: string }, at: { type: string, format: date-time }, share: { type: boolean } } }
        demo: { type: boolean }
        verification: { $ref: '#/components/schemas/Verification' }
    DataIdStatus:
      type: object
      properties:
        valid: { type: boolean }
        travelId: { type: string }
        sub: { type: string }
        kind: { type: string, enum: [vn, intl] }
        status: { type: string, enum: [active, locked] }
        active: { type: boolean }
        issuedAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        lockedAt: { type: string, format: date-time, nullable: true }
        maskedName: { type: string, example: N. V. An }
        demo: { type: boolean }
    DataTransactionIn:
      type: object
      required: [type, ref]
      properties:
        type: { type: string, example: booking }
        ref: { type: string, description: "Mã đơn phía bạn – idempotent theo (nguồn, travelId, ref)" }
        amount: { type: number }
        currency: { type: string, example: VND }
        description: { type: string }
        occurredAt: { type: string, format: date-time }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: validation }
            message: { type: string, example: Thông tin chưa hợp lệ. }
            errors: { type: object, additionalProperties: { type: string }, example: { dob: Ngày sinh không hợp lệ. } }
    Tokens:
      type: object
      properties:
        tokenType: { type: string, example: Bearer }
        accessToken: { type: string, example: tqa_eyJ0IjoiYSIsInNpZCI6Ii4uLiJ9.c2ln }
        expiresIn: { type: integer, example: 900 }
        refreshToken: { type: string, example: tqr_Xc81kPq2LmZt.R4nd0mS3cr3t }
        refreshExpiresIn: { type: integer, example: 2592000 }
    AuthResult:
      allOf:
        - $ref: '#/components/schemas/Tokens'
        - type: object
          properties:
            ok: { type: boolean }
            user: { type: object, properties: { sub: { type: string }, login: { type: string, example: '0912345678' }, loginType: { type: string, enum: [phone, email] }, tqid: { type: string, nullable: true } } }
    Personal:
      type: object
      required: [kind, name, birthYear]
      description: "Từ d5 chỉ bắt buộc kind, name, birthYear (dob, gender là tuỳ chọn/cũ). Vẫn nhận tên cũ v1.0 (fullName, dob ISO, gender nam/nu/khac)."
      properties:
        kind: { type: string, enum: [Công dân Việt Nam, Người nước ngoài] }
        name: { type: string, example: Nguyễn Minh An }
        birthYear: { type: integer, example: 1995, description: "Năm sinh yyyy, trong khoảng (năm nay − 120) … năm nay. Có thể suy ra từ dob." }
        dob: { type: string, example: 12/04/1995, description: "Tuỳ chọn (cũ): dd/mm/yyyy, yyyy-mm-dd hoặc chỉ yyyy" }
        gender: { type: string, enum: [Nam, Nữ, Khác], description: Tuỳ chọn (cũ) }
        nationality: { type: string, description: "ISO 3166-1 alpha-2; bắt buộc với kind Người nước ngoài (khách quốc tế), mặc định VN", example: VN }
    Document:
      type: object
      description: "Khách Việt Nam (Công dân VN) gửi cccd; khách quốc tế (Người nước ngoài) gửi passport + passportCountry (+ passportExp). Chỉ lưu bản băm + bản che. Vẫn nhận docType/docNumber (v1.0)."
      properties:
        cccd: { type: string, pattern: '^\d{12}$', example: '000000000201' }
        passport: { type: string, example: M1234567 }
        passportCountry: { type: string, example: KR, description: Tuỳ chọn – mặc định theo quốc tịch }
        passportExp: { type: string, example: 01/01/2031 }
    Kin:
      type: object
      required: [name, phone]
      properties:
        name: { type: string, example: Trần Thị Hoa }
        rel: { type: string, example: Mẹ }
        phone: { type: string, example: '0987654321' }
        emg: { type: boolean, description: Liên hệ khi khẩn cấp }
        view: { type: boolean, description: Được xem thông tin chuyến đi }
    Contact:
      type: object
      required: [phone, prov]
      properties:
        phone: { type: string, example: '0912345678' }
        email: { type: string, example: an@example.com }
        street: { type: string, example: 12 Lê Lợi }
        ward: { type: string, example: Phường Hải Châu }
        prov: { type: string, description: "Công dân VN – một trong 34 tỉnh/thành (xem /meta). Khách quốc tế – tự nhập tỉnh/thành phố nơi sống (2–80 ký tự). Vẫn nhận province (v1.0).", example: Đà Nẵng }
        country: { type: string, description: "Quốc gia (ISO 3166-1 alpha-2). Công dân VN luôn là VN; khách quốc tế theo danh mục /meta.", example: VN }
        kin: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/Kin' } }
    CoreProfile:
      type: object
      description: Hồ sơ lõi (chỉ đọc qua API; dịch vụ chỉ thấy phần theo scope)
      properties:
        sub: { type: string, example: u_Ab12Cd34Ef56 }
        tqid: { type: string, example: TQ482019379 }
        status: { type: string, enum: [active, locked] }
        kind: { type: string }
        name: { type: string }
        birthYear: { type: integer, example: 1995 }
        dob: { type: string, example: 12/04/1995, description: "Có thể rỗng (từ d5 chỉ thu năm sinh)" }
        birthdate: { type: string, description: "yyyy-mm-dd, hoặc chỉ yyyy khi chỉ có năm sinh (theo OIDC). Chỉ trong view dịch vụ/OIDC" }
        gender: { type: string }
        nationality: { type: string }
        docType: { type: string, enum: [cccd, passport] }
        cccdMasked: { type: string, example: '001•••••345' }
        passportMasked: { type: string }
        passportCountry: { type: string }
        passportExp: { type: string }
        phone: { type: string }
        email: { type: string }
        street: { type: string }
        ward: { type: string }
        prov: { type: string }
        country: { type: string, example: VN }
        countryName: { type: string, example: Việt Nam }
        addr: { type: string, example: '12 Lê Lợi, Phường Hải Châu, Đà Nẵng' }
        kin: { type: array, items: { $ref: '#/components/schemas/Kin' } }
        interests: { type: array, items: { type: string } }
        languages: { type: array, items: { type: string } }
    WebhookEvent:
      type: object
      properties:
        id: { type: string, example: evt_x1y2z3 }
        type: { type: string, enum: [id.issued, profile.updated, id.locked, id.unlocked, ping] }
        createdAt: { type: string, format: date-time }
        data: { type: object, properties: { sub: { type: string }, tqid: { type: string }, status: { type: string }, changed: { type: array, items: { type: string } } } }
    Preferences:
      type: object
      required: [languages]
      properties:
        interests: { type: array, items: { type: string }, example: [bien-dao, am-thuc] }
        languages: { type: array, items: { type: string }, example: [vi, en] }
    Card:
      type: object
      properties:
        tqid: { type: string, example: TQ482019379 }
        name: { type: string }
        nationality: { type: string }
        nationalityName: { type: string }
        issuedAt: { type: string, format: date-time }
        status: { type: string, enum: [active, locked] }
        qrPayload: { type: string, example: 'TQID:TQ482019379' }
        qrSvgUrl: { type: string }
        demo: { type: boolean }
        note: { type: string }
    Transaction:
      type: object
      properties:
        id: { type: string }
        tqid: { type: string }
        site: { type: string, example: hotel }
        siteName: { type: string }
        type: { type: string, enum: [booking, payment, refund, order, ticket, checkin, checkout, passport, voucher, other] }
        amount: { type: number, nullable: true }
        currency: { type: string, example: VND }
        ref: { type: string, example: HD-10293 }
        description: { type: string }
        occurredAt: { type: string, format: date-time }
        recordedAt: { type: string, format: date-time }
  responses:
    Err: { description: Lỗi, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
security: []
paths:
  /health:
    get: { tags: [Chung], summary: Kiểm tra dịch vụ, responses: { '200': { description: OK, content: { application/json: { example: { ok: true, service: travelq-id, version: '1.1.0' } } } } } }
  /meta:
    get: { tags: [Chung], summary: Danh mục (tỉnh/thành, quốc tịch, sở thích, ngôn ngữ, nội dung đồng ý, chế độ OTP), responses: { '200': { description: OK } } }
  /auth/otp/request:
    post:
      tags: [Xác thực]
      summary: Gửi mã OTP (đăng ký hoặc đặt lại mật khẩu)
      description: "Bản thử nghiệm: `simulated: true` và trả `demoCode`. Khi gắn nhà cung cấp SMS/Zalo thật (OTP_PROVIDER=webhook) sẽ không trả mã. Chờ 30 giây giữa hai lần gửi."
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [login], properties: { login: { type: string }, purpose: { type: string, enum: [register, reset, login] } } }, example: { login: '0912345678', purpose: register } } } }
      responses:
        '200': { description: Đã gửi, content: { application/json: { example: { sent: true, channel: sms, expiresIn: 300, simulated: true, demoCode: '482913' } } } }
        '409': { $ref: '#/components/responses/Err' }
        '429': { $ref: '#/components/responses/Err' }
  /auth/register:
    post:
      tags: [Xác thực]
      summary: Tạo tài khoản (client=app → trả token)
      requestBody: { required: true, content: { application/json: { example: { login: '0912345678', password: 'Matkhau2026', otp: '482913', client: app, deviceName: 'iPhone của An' } } } }
      responses:
        '201': { description: Đã tạo, content: { application/json: { schema: { $ref: '#/components/schemas/AuthResult' } } } }
        '400': { $ref: '#/components/responses/Err' }
        '409': { $ref: '#/components/responses/Err' }
        '422': { $ref: '#/components/responses/Err' }
  /auth/login:
    post:
      tags: [Xác thực]
      summary: Đăng nhập bằng SĐT/email + mật khẩu
      description: "`client`: `app` (token), `embed` (token, dùng cho webview), bỏ trống = `web` (cookie)."
      requestBody: { required: true, content: { application/json: { example: { login: '0912345678', password: 'Matkhau2026', client: app, deviceName: 'Galaxy S22 Ultra' } } } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/AuthResult' }, example: { ok: true, user: { login: '0912345678', loginType: phone, tqid: null }, tokenType: Bearer, accessToken: tqa_…, expiresIn: 900, refreshToken: tqr_…, refreshExpiresIn: 2592000 } } } }
        '401': { $ref: '#/components/responses/Err' }
  /auth/otp/verify:
    post:
      tags: [Xác thực]
      summary: Đăng nhập không mật khẩu bằng OTP (SĐT hoặc Gmail) – tự tạo tài khoản nếu chưa có
      description: "Gửi OTP trước với purpose=login. `via`: phone | gmail | email. 201 = tài khoản mới, 200 = đã có."
      requestBody: { required: true, content: { application/json: { example: { login: an.nguyen@gmail.com, otp: '482913', via: gmail, client: app, deviceName: 'iPhone của An' } } } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/AuthResult' } } } }
        '201': { description: Tài khoản mới, content: { application/json: { schema: { $ref: '#/components/schemas/AuthResult' } } } }
        '400': { $ref: '#/components/responses/Err' }
  /auth/google:
    post:
      tags: [Xác thực]
      summary: Đăng nhập bằng Google ID token (khi cấu hình GOOGLE_CLIENT_ID; chưa cấu hình → 501)
      requestBody: { required: true, content: { application/json: { example: { idToken: eyJ…, client: app } } } }
      responses: { '200': { description: OK }, '401': { $ref: '#/components/responses/Err' }, '501': { $ref: '#/components/responses/Err' } }
  /auth/demo:
    post: { tags: [Xác thực], summary: "Tài khoản demo dùng chung (chỉ khi TQID_DEMO=1, dữ liệu hư cấu)", responses: { '200': { description: OK }, '404': { description: Tắt ở môi trường thật } } }
  /me/wizard/scan:
    post: { tags: [Bộ lập ID], summary: "Quét CCCD / VNeID – MÔ PHỎNG (TQID_DEMO=1): trả dữ liệu mẫu để điền nhanh, chưa kết nối VNeID", security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { example: { simulated: true, note: Mô phỏng, data: { kind: Công dân Việt Nam, name: …, dob: …, gender: …, cccd: …, street: …, ward: …, prov: … } } } } } } }
  /auth/refresh:
    post:
      tags: [Xác thực]
      summary: Làm mới access token (refresh token xoay vòng)
      description: "Mỗi refreshToken dùng **một lần**; luôn lưu token mới. Dùng lại token cũ → 401 `refresh_reused` và phiên bị thu hồi."
      requestBody: { required: true, content: { application/json: { example: { refreshToken: tqr_Xc81kPq2LmZt.R4nd0mS3cr3t } } } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Tokens' } } } }
        '401': { $ref: '#/components/responses/Err' }
  /auth/logout:
    post: { tags: [Xác thực], summary: Đăng xuất (thu hồi phiên hiện tại), security: [{ bearer: [] }], responses: { '200': { description: OK } } }
  /auth/session:
    get: { tags: [Xác thực], summary: Trạng thái phiên, security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { example: { authenticated: true, account: { login: '0912345678', loginType: phone }, tqid: null, hasTravelId: false, onboardingUrl: /bat-dau/, wizard: { step: personal, completed: [] }, session: { sid: '…', kind: app, device: 'iPhone của An' } } } } } } }
  /auth/password/reset:
    post:
      tags: [Xác thực]
      summary: Đặt lại mật khẩu bằng OTP (purpose=reset); mọi phiên cũ bị thu hồi
      requestBody: { required: true, content: { application/json: { example: { login: '0912345678', otp: '123456', newPassword: 'MatkhauMoi2026' } } } }
      responses: { '200': { description: OK }, '400': { $ref: '#/components/responses/Err' } }
  /me:
    get: { tags: [Hồ sơ], summary: Tài khoản + ID (nếu đã cấp) + tiến độ bộ lập, security: [{ bearer: [] }], responses: { '200': { description: OK } } }
  /me/wizard:
    get:
      tags: [Bộ lập ID]
      summary: Tiến độ bộ lập ID
      security: [{ bearer: [] }]
      responses: { '200': { description: OK, content: { application/json: { examples: { dang_lap: { value: { issued: false, step: document, steps: [personal, document, contact, preferences, review], completed: [personal], data: { personal: { kind: Công dân Việt Nam, name: Nguyễn Minh An, dob: 12/04/1995, gender: Nam, nationality: VN } }, prefill: { phone: '0912345678' } } }, da_cap: { value: { issued: true, tqid: TQ482019379 } } } } } } }
  /me/wizard/{step}:
    put:
      tags: [Bộ lập ID]
      summary: Lưu một bước (personal → document → contact → preferences, đúng thứ tự)
      security: [{ bearer: [] }]
      parameters: [{ name: step, in: path, required: true, schema: { type: string, enum: [personal, document, contact, preferences] } }]
      requestBody: { required: true, content: { application/json: { schema: { oneOf: [ { $ref: '#/components/schemas/Personal' }, { $ref: '#/components/schemas/Document' }, { $ref: '#/components/schemas/Contact' }, { $ref: '#/components/schemas/Preferences' } ] } } } }
      responses:
        '200': { description: Đã lưu, content: { application/json: { example: { ok: true, step: document, data: { docType: cccd, cccdMasked: '001•••••345' }, next: contact } } } }
        '409': { description: "step_order (chưa xong bước trước), duplicate_document, already_issued", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { $ref: '#/components/responses/Err' }
  /me/wizard/basic:
    put:
      tags: [Bộ lập ID]
      summary: "Biểu mẫu rút gọn 1 trang (d5) – 7 trường: name, birthYear, cccd|passport, street, phone, prov, country"
      description: "Kiểm tra cả 7 trường một lần, ghi vào các bước personal/document/contact (preferences rỗng), rồi gọi /me/wizard/finish. Lỗi trả theo tên trường (name, birthYear, cccd, passport, street, phone, prov, country, kind)."
      security: [{ bearer: [] }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [kind, name, birthYear, street, phone, prov], properties: {
        kind: { type: string, enum: [Công dân Việt Nam, Người nước ngoài] }, name: { type: string, example: Nguyễn Minh An }, birthYear: { type: integer, example: 1995 },
        cccd: { type: string, description: "Công dân VN – đúng 12 chữ số" }, passport: { type: string, description: "Khách quốc tế – 6–9 chữ/số" },
        street: { type: string, example: 12 Lê Lợi }, phone: { type: string, description: "VN 0xxxxxxxxx; khách quốc tế có thể dùng +mã nước", example: '0912345678' },
        prov: { type: string, example: Đà Nẵng }, country: { type: string, description: "VN cho công dân; mã nước cho khách quốc tế", example: KR } } },
        example: { kind: Người nước ngoài, name: Emma Johnson, birthYear: 1990, passport: X1234567, street: 21 King St, phone: '+61412345678', prov: Sydney, country: AU } } } }
      responses:
        '200': { description: Đã lưu – sang bước xác nhận, content: { application/json: { example: { ok: true, step: basic, next: review } } } }
        '409': { description: "duplicate_document, already_issued", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { $ref: '#/components/responses/Err' }
  /me/wizard/kyc:
    get:
      tags: [Bộ lập ID]
      summary: "d15: trạng thái bước Giấy tờ + Khuôn mặt của bản nháp (sau khi cấp ID → 409 already_issued; xem /me/profile → verification)"
      security: [{ bearer: [] }]
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/KycState' } } } }, '409': { $ref: '#/components/responses/Err' } }
  /me/wizard/document-image:
    put:
      tags: [Bộ lập ID]
      summary: "d21: tải ảnh giấy tờ (CCCD mặt trước/mặt sau hoặc trang ảnh hộ chiếu) – CHỈ sau khi khách tick đồng ý (a) ở bước Xác nhận và bấm Hoàn tất; trước đó ảnh nằm trên thiết bị. Bắt buộc consent=true + consentVersion=2026-10-v4, thiếu → 422 consent_required. Ảnh được mã hoá AES-GCM bằng DATA_ENCRYPTION_KEY trước khi lưu; chưa có khoá → không lưu ảnh (stored=false)."
      security: [{ bearer: [] }]
      requestBody: { required: true, content: { application/json: { example: { side: front, image: "data:image/jpeg;base64,…", consent: true, consentVersion: 2026-10-v4, source: camera, quality: { brightness: 128.4, contrast: 52.1, sharpness: 18.7 } } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { ok: true, side: front, stored: true, kyc: {} } } } }
        '413': { $ref: '#/components/responses/Err' }
        '422': { description: "validation (mặt không hợp lệ / ảnh quá nhỏ), consent_required", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /me/wizard/face:
    post:
      tags: [Bộ lập ID]
      summary: "d21: nhận diện khuôn mặt – CHỈ gửi khi khách tick đồng ý (b) (không bắt buộc) ở bước Xác nhận; bắt buộc faceConsent=true + consentVersion=2026-10-v4 và đã có đồng ý (a), thiếu → 422 consent_required. So liveness + khớp với ảnh giấy tờ (điểm MÔ PHỎNG); ảnh khuôn mặt được lưu mã hoá đến khi khách yêu cầu xoá, dùng cho check-in bằng khuôn mặt tại khách sạn."
      security: [{ bearer: [] }]
      requestBody: { required: true, content: { application/json: { example: { image: "data:image/jpeg;base64,…", faceConsent: true, consentVersion: 2026-10-v4, mode: camera, liveness: { motion: 3.4, frames: 24, prompts: [blink, turn] } } } } }
      responses:
        '200': { description: "ok=true khi liveness ≥ 0.90 và match ≥ 0.80; status=failed (còn lượt) hoặc manual_review (hết 2 lượt)", content: { application/json: { example: { ok: true, status: face_verified, attempts: 1, liveness: 0.951, match: 0.874, method: simulated, thresholds: { liveness: 0.9, match: 0.8, maxAttempts: 2 }, canRetry: false } } } }
        '409': { description: "step_order (chưa chụp giấy tờ), face_attempts_exceeded", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { $ref: '#/components/responses/Err' }
  /me/wizard/face/manual:
    post:
      tags: [Bộ lập ID]
      summary: "d15: sau lần thử không đạt – tiếp tục, để TravelQ xét duyệt thủ công (status=manual_review)"
      security: [{ bearer: [] }]
      responses: { '200': { description: OK }, '409': { $ref: '#/components/responses/Err' } }
  /me/wizard/finish:
    post:
      tags: [Bộ lập ID]
      summary: "Hoàn tất – cấp TravelQ ID (đồng ý theo từng mục đích; terms + travel bắt buộc). d15: phiên web/nhúng phải xong đồng ý sinh trắc học + ảnh giấy tờ + khuôn mặt, nếu không → 409 kyc_missing"
      security: [{ bearer: [] }]
      requestBody: { required: true, content: { application/json: { example: { consent: { terms: true, travel: true, share: false }, photoConsent: true, faceConsent: false } } } }
      responses:
        '201': { description: Đã cấp ID, content: { application/json: { example: { ok: true, tqid: TQ482019379, message: Bạn đã có ID định danh du lịch., card: { tqid: TQ482019379, name: Nguyễn Minh An, nationality: VN, nationalityName: Việt Nam, status: active, qrPayload: 'TQID:TQ482019379', demo: true }, profile: {} } } } }
        '409': { $ref: '#/components/responses/Err' }
        '422': { $ref: '#/components/responses/Err' }
  /me/wizard/doc-read:
    post:
      tags: [Bộ lập ID]
      summary: "d22: AI đọc giấy tờ (TẮT mặc định) – dự phòng im lặng khi đọc trên thiết bị thất bại"
      description: |
        Chỉ hoạt động khi máy chủ cấu hình DOCAI_ENABLED=1 + DOCAI_URL (xem DocAiProviderRequest/Response). Ứng dụng web gọi đúng 1 lần ở bước Xác nhận,
        SAU khi khách tick đồng ý và ảnh mặt trước/hộ chiếu đã được tải lên, và chỉ khi đọc trên thiết bị thất bại (`onDevice: failed`).
        Ảnh gửi kèm phải trùng ảnh đã tải (so SHA-256). Máy chủ chỉ điền ô còn trống (ngày sinh, giới tính); tên/năm sinh lệch → gắn cờ cho nhân viên.
        Giới hạn 10 lần/giờ/tài khoản. GET /me/wizard → `docAi: {enabled, provider}` cho biết có bật hay không.
      security: [{ bearer: [] }]
      requestBody: { required: true, content: { application/json: { example: { image: "data:image/jpeg;base64,…", consent: true, consentVersion: 2026-10-v4, onDevice: failed, lang: vi } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { ok: true, provider: my-ai, filled: [dob, gender], mismatch: [name] } } } }
        '409': { description: "docai_not_needed (onDevice ≠ failed), step_order (chưa tải ảnh), image_mismatch", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: "consent_required, docai_failed (nhà cung cấp lỗi/hết giờ – ứng dụng bỏ qua im lặng)", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '503': { description: "docai_unavailable – chưa bật (mặc định)", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /me/document-images/delete:
    post:
      tags: [Hồ sơ]
      summary: "d21: Yêu cầu xoá ảnh giấy tờ và khuôn mặt – xoá cả ảnh giấy tờ lẫn ảnh khuôn mặt đã lưu (check-in bằng khuôn mặt tắt theo). Cần confirm=true."
      security: [{ bearer: [] }]
      requestBody: { required: true, content: { application/json: { example: { confirm: true } } } }
      responses: { '200': { description: OK }, '422': { $ref: '#/components/responses/Err' } }
  /me/profile:
    get: { tags: [Hồ sơ], summary: Hồ sơ đầy đủ của chính du khách (số giấy tờ đã che), security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CoreProfile' } } } } } }
    patch:
      tags: [Hồ sơ]
      summary: Sửa hồ sơ (không sửa được ID, kind, ngày sinh, quốc tịch, số giấy tờ → 422 field_locked)
      security: [{ bearer: [] }]
      requestBody: { content: { application/json: { example: { email: an.moi@example.com, street: 5 Trần Phú, ward: Phường Vĩnh Ninh, prov: Huế, kin: [{ name: Trần Thị Hoa, rel: Mẹ, phone: '0987654321', emg: true }], interests: [van-hoa, am-thuc] } } } }
      responses: { '200': { description: "OK; gửi webhook profile.updated", content: { application/json: { example: { ok: true, changed: [email, street, ward, prov, addr, kin, interests], profile: {} } } } }, '422': { $ref: '#/components/responses/Err' } }
  /me/card:
    get: { tags: [Hồ sơ], summary: Dữ liệu thẻ ID số, security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Card' } } } } } }
  /me/card/qr.svg:
    get: { tags: [Hồ sơ], summary: Mã QR (SVG) chứa "TQID:<id>", security: [{ bearer: [] }], responses: { '200': { description: SVG, content: { image/svg+xml: {} } } } }
  /me/transactions:
    get: { tags: [Hồ sơ], summary: Giao dịch gắn với ID của tôi, security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { schema: { type: object, properties: { tqid: { type: string }, items: { type: array, items: { $ref: '#/components/schemas/Transaction' } } } } } } } } }
  /me/consents:
    get: { tags: [Hồ sơ], summary: Các dịch vụ và phạm vi dữ liệu đã cho phép, security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { example: { scopes: [{ key: profile, label: Thông tin cơ bản }], sites: [{ site: hotel, name: Khách sạn TravelQ, scopes: [profile], grantedAt: '2026-10-29T03:00:00.000Z' }] } } } } } }
  /me/consents/{site}:
    put:
      tags: [Hồ sơ]
      summary: Đặt phạm vi chia sẻ cho một dịch vụ (mảng rỗng = thu hồi)
      security: [{ bearer: [] }]
      parameters: [{ name: site, in: path, required: true, schema: { type: string } }]
      requestBody: { content: { application/json: { example: { scopes: [profile, contact] } } } }
      responses: { '200': { description: "OK – contact được tách thành email, phone, address", content: { application/json: { example: { site: hotel, scopes: [profile, email, phone, address] } } } } }
  /me/services:
    get: { tags: [Hồ sơ], summary: Ứng dụng đã kết nối (OIDC/chia sẻ) + hồ sơ dịch vụ liên kết, security: [{ bearer: [] }], responses: { '200': { description: OK, content: { application/json: { example: { items: [{ site: app, name: Ứng dụng TravelQ, scopes: [profile, email], via: oauth, grantedAt: '2026-10-29T03:00:00.000Z', link: { externalId: HV-00123 } }] } } } } } }
  /me/services/{site}:
    delete: { tags: [Hồ sơ], summary: "Ngắt kết nối ứng dụng: xoá quyền, gỡ liên kết, thu hồi refresh token", security: [{ bearer: [] }], parameters: [{ name: site, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /me/sessions:
    get: { tags: [Hồ sơ], summary: Thiết bị đang đăng nhập, security: [{ bearer: [] }], responses: { '200': { description: OK } } }
  /me/sessions/{sid}:
    delete: { tags: [Hồ sơ], summary: Đăng xuất một thiết bị, security: [{ bearer: [] }], parameters: [{ name: sid, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /partner/me:
    get: { tags: [Dịch vụ], summary: Thông tin key đang dùng, security: [{ apiKey: [] }], responses: { '200': { description: OK } } }
  /checkin/face-verify:
    post:
      tags: [Dịch vụ]
      summary: "d21: check-in bằng khuôn mặt tại khách sạn – so selfie tại quầy với ảnh khuôn mặt TravelQ đã lưu (mã hoá). Chỉ khoá API nội bộ (firstParty) được bật quyền checkin:face; chỉ dùng được với du khách đã tick đồng ý (b). KHÔNG trả ảnh, chỉ trả kết quả. Mọi lượt gọi ghi nhật ký checkin.face_verify. Bộ so khớp hiện là MÔ PHỎNG."
      security: [{ apiKey: [] }]
      requestBody: { required: true, content: { application/json: { example: { tqId: TQ482019379, selfie: "data:image/jpeg;base64,…" } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { tqId: TQ482019379, match: true, score: 0.874, threshold: 0.8, method: simulated, checkedAt: "2026-10-31T04:00:00.000Z", maskedName: N. M. An } } } }
        '400': { description: invalid_tqid, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '401': { description: "thiếu/sai X-API-Key", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: "scope_not_allowed – khoá không có quyền checkin:face", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: not_found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: "face_not_enrolled – du khách chưa đồng ý check-in bằng khuôn mặt hoặc đã xoá ảnh → check-in bằng giấy tờ", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: "validation – thiếu selfie", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '423': { description: id_locked, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /partner/ids/{tqid}/verify:
    get:
      tags: [Dịch vụ]
      summary: Xác minh ID (trạng thái + tên đã che) – không cần du khách cho phép
      security: [{ apiKey: [] }]
      parameters: [{ $ref: '#/components/parameters/tqid' }]
      responses:
        '200': { description: OK, content: { application/json: { example: { tqid: TQ482019379, valid: true, status: active, maskedName: N. M. An, nationality: VN, issuedYear: 2026, demo: true } } } }
        '400': { $ref: '#/components/responses/Err' }
        '404': { description: Không có ID, content: { application/json: { example: { tqid: TQ482019379, valid: false, error: { code: not_found } } } } }
  /partner/ids/{tqid}:
    get:
      tags: [Dịch vụ]
      summary: Tra cứu hồ sơ theo phạm vi du khách đã cho phép
      security: [{ apiKey: [] }]
      parameters: [{ $ref: '#/components/parameters/tqid' }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CoreProfile' }, example: { sub: u_Ab12Cd34Ef56, tqid: TQ482019379, status: active, scopes: [profile, email, phone, address], kind: Công dân Việt Nam, name: Nguyễn Minh An, dob: 12/04/1995, birthdate: '1995-04-12', gender: Nam, nationality: VN, phone: '0912345678', phoneE164: '+84912345678', email: an@example.com, street: 12 Lê Lợi, ward: Phường Hải Châu, prov: Đà Nẵng, addr: '12 Lê Lợi, Phường Hải Châu, Đà Nẵng' } } } }
        '403': { description: consent_required – du khách chưa cho phép, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /partner/ids/{tqid}/transactions:
    post:
      tags: [Dịch vụ]
      summary: Ghi nhận giao dịch vào ID (idempotent theo ref)
      description: Gửi lại cùng `ref` → 200 `idempotent:true`, không tạo bản ghi mới. ID bị khoá → 423.
      security: [{ apiKey: [] }]
      parameters: [{ $ref: '#/components/parameters/tqid' }]
      requestBody: { required: true, content: { application/json: { example: { type: booking, ref: HD-10293, amount: 1250000, currency: VND, description: Đặt phòng 2 đêm, occurredAt: '2026-10-29T09:30:00+07:00' } } } }
      responses:
        '201': { description: Đã ghi, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, transaction: { $ref: '#/components/schemas/Transaction' } } } } } }
        '200': { description: Trùng ref, content: { application/json: { example: { ok: true, idempotent: true, transactionId: tx_…, tqid: TQ482019379 } } } }
        '422': { $ref: '#/components/responses/Err' }
        '423': { $ref: '#/components/responses/Err' }
    get:
      tags: [Dịch vụ]
      summary: Giao dịch của ID (tại dịch vụ của bạn; mọi dịch vụ nếu du khách cho phép "transactions")
      security: [{ apiKey: [] }]
      parameters: [{ $ref: '#/components/parameters/tqid' }]
      responses: { '200': { description: OK, content: { application/json: { example: { tqid: TQ482019379, scope: own_site, items: [] } } } } }
  /partner/users/{sub}:
    get: { tags: [Dịch vụ], summary: Hồ sơ theo sub (phạm vi được phép) + liên kết của dịch vụ, security: [{ apiKey: [] }], parameters: [{ name: sub, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK }, '403': { $ref: '#/components/responses/Err' }, '404': { $ref: '#/components/responses/Err' } } }
  /partner/users/{sub}/link:
    put:
      tags: [Dịch vụ]
      summary: Liên kết hồ sơ dịch vụ (mã khách phía bạn) với tài khoản lõi
      security: [{ apiKey: [] }]
      parameters: [{ name: sub, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { example: { externalId: HV-00123, label: Hội viên Vàng } } } }
      responses: { '200': { description: OK }, '403': { $ref: '#/components/responses/Err' }, '409': { description: external_id_taken }, '422': { $ref: '#/components/responses/Err' } }
    delete: { tags: [Dịch vụ], summary: Gỡ liên kết, security: [{ apiKey: [] }], parameters: [{ name: sub, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /partner/links/{externalId}:
    get: { tags: [Dịch vụ], summary: Tra ngược sub/tqid từ mã khách phía bạn, security: [{ apiKey: [] }], parameters: [{ name: externalId, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK, content: { application/json: { example: { sub: u_Ab12Cd34Ef56, tqid: TQ482019379, status: active, link: { externalId: HV-00123 } } } } }, '404': { $ref: '#/components/responses/Err' } } }
  /partner/users/{sub}/document/match:
    post:
      tags: [Dịch vụ]
      summary: Kiểm tra số giấy tờ khách đưa có khớp ID không (cần scope document; không bao giờ trả số đầy đủ)
      security: [{ apiKey: [] }]
      parameters: [{ name: sub, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { example: { cccd: '000000000201' } } } }
      responses: { '200': { description: OK, content: { application/json: { example: { sub: u_Ab12Cd34Ef56, docType: cccd, match: true } } } }, '403': { $ref: '#/components/responses/Err' } }
  /recover/start:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22: bắt đầu lấy lại tài khoản – số giấy tờ + năm sinh (không cần đăng nhập)"
      description: "Không lộ bất kỳ trường hồ sơ nào; chỉ trả kênh liên hệ dạng che. Sai năm sinh và sai OTP cộng chung bộ đếm theo giấy tờ: 5 lần → khoá 60 phút (429 recover_locked). 20 lượt/giờ/IP."
      requestBody: { required: true, content: { application/json: { example: { docType: cccd, docNumber: '0XXXXXXXXXXX', birthYear: '1990' } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { ok: true, rid: "…", channels: [{ channel: sms, masked: '09•••678' }], pendingRequest: false, expiresIn: 1800 } } } }
        '404': { description: recover_not_found }
        '422': { $ref: '#/components/responses/Err' }
        '423': { description: id_locked }
        '429': { description: "recover_locked, rate_limited" }
  /recover/otp:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22 – Đường A: gửi OTP tới SĐT/email ĐÃ LƯU (5 OTP/giờ/giấy tờ, chờ 30 giây giữa 2 lần)"
      requestBody: { required: true, content: { application/json: { example: { rid: "…", channel: sms } } } }
      responses:
        '200': { description: "demoCode CHỈ có khi OTP_PROVIDER mô phỏng", content: { application/json: { example: { ok: true, sent: true, channel: sms, masked: '09•••678', expiresIn: 300, simulated: true, demoCode: '123456' } } } }
        '400': { description: recover_expired }
        '422': { description: no_channel }
        '429': { description: "otp_cooldown, rate_limited, recover_locked" }
  /recover/verify:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22: nhập OTP → đăng nhập vào hồ sơ cũ (đặt cookie phiên / trả token), bỏ bản nháp đang dở"
      requestBody: { required: true, content: { application/json: { example: { rid: "…", otp: '123456' } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { ok: true, tqid: TQ482019379, display: TQ 482 019 379, draftDiscarded: true, user: { sub: u_…, tqid: TQ482019379, hasTravelId: true } } } } }
        '400': { description: "otp_invalid, otp_expired, recover_expired" }
        '429': { description: recover_locked }
  /recover/image:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22 – Đường B: tải ảnh giấy tờ / khuôn mặt (CHỈ sau khi tick đồng ý; ≤ 2,4 MB; lưu mã hoá)"
      requestBody: { required: true, content: { application/json: { example: { rid: "…", side: front, image: "data:image/jpeg;base64,…", consent: true, consentVersion: 2026-10-v4 } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { ok: true, side: front, stored: true } } } }
        '422': { description: "consent_required, validation" }
        '503': { description: key_missing }
  /recover/request:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22 – Đường B: gửi yêu cầu (số điện thoại mới) vào hàng chờ nhân viên /quet/"
      requestBody: { required: true, content: { application/json: { example: { rid: "…", newPhone: '09XXXXXXXX', consent: true, consentVersion: 2026-10-v4 } } } }
      responses:
        '201': { description: "Giữ lại ticket để tra trạng thái", content: { application/json: { example: { ok: true, requestId: "…", ticket: "…", status: pending, newPhoneMasked: '09•••321' } } } }
        '409': { description: request_pending }
        '422': { description: "consent_required, images_missing, validation" }
  /recover/request/status:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22: tra trạng thái theo ticket; khi đã duyệt → trả rid để gửi OTP tới SỐ MỚI (/recover/otp → /recover/verify)"
      requestBody: { required: true, content: { application/json: { example: { ticket: "…" } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { ok: true, requestId: "…", status: approved, newPhoneMasked: '09•••321', rid: "…", channels: [{ channel: sms, masked: '09•••321' }] } } } }
        '404': { description: request_not_found }
  /scan/recovery:
    get:
      tags: [Lấy lại tài khoản]
      summary: "d22 – nhân viên /quet/ (vai trò scanner): hàng chờ \"Yêu cầu lấy lại tài khoản\""
      responses: { '200': { description: "{ok, items:[…]}" }, '403': { description: forbidden } }
  /scan/recovery/{id}/images:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22 – nhân viên xem ảnh yêu cầu + ảnh khuôn mặt đã lưu (ghi nhật ký)"
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { '200': { description: "{ok, images, storedFace}" }, '410': { description: images_deleted } }
  /scan/recovery/{id}/{act}:
    post:
      tags: [Lấy lại tài khoản]
      summary: "d22 – duyệt (đổi SĐT hồ sơ) hoặc từ chối (bắt buộc lý do); ghi nhật ký"
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }, { name: act, in: path, required: true, schema: { type: string, enum: [approve, reject] } }]
      requestBody: { content: { application/json: { example: { reason: Ảnh không khớp } } } }
      responses: { '200': { description: "{ok, item}" }, '409': { description: already_decided }, '422': { description: validation } }
  /admin/travellers/{tqid}/photos:
    post:
      tags: [Quản trị]
      summary: "d24 – Xem hồ sơ → Xem ảnh: giải mã ảnh giấy tờ + ảnh khuôn mặt. Chỉ quản trị cao nhất (đăng nhập \"admin\"); cần CSRF; 60 lượt/giờ; mỗi lượt ghi nhật ký admin.view_photos (quản trị, ID, thời gian, IP). Cache-Control: no-store; giao diện tự ẩn sau 30 giây. Ảnh đã xoá / thiếu khoá → images rỗng."
      parameters: [{ name: tqid, in: path, required: true, schema: { type: string } }]
      responses: { '200': { description: "{tqid, status: ok|deleted|not_stored|none|key_missing|decrypt_failed, hideAfterSec: 30, images: [{side, label, capturedAt, image}]}" }, '403': { description: "forbidden_role (quản trị hỗ trợ) | csrf" }, '404': { description: not_found }, '429': { description: rate_limited } }
  /admin/apps:
    post:
      tags: [Quản trị]
      summary: Đăng ký ứng dụng (client OIDC + API key + webhook). Bí mật chỉ trả một lần.
      requestBody: { required: true, content: { application/json: { example: { name: Ứng dụng TravelQ, site: app, clientType: public, redirectUris: ['travelq://oauth/callback'], allowedScopes: [openid, profile, email, phone, offline_access], webhookUrl: 'https://app.example/hooks/travelq', events: [profile.updated, id.locked], firstParty: true, description: Ứng dụng di động TravelQ } } } }
      responses: { '201': { description: Đã tạo, content: { application/json: { example: { clientId: tqc_ab12cd34ef, apiKey: tqk_…, clientSecret: "(chỉ với confidential, = apiKey)", webhookSecret: whsec_…, app: {} } } } }, '422': { $ref: '#/components/responses/Err' } }
    get: { tags: [Quản trị], summary: Danh sách ứng dụng, responses: { '200': { description: OK } } }
  /admin/keys/{id}:
    patch: { tags: [Quản trị], summary: "Sửa ứng dụng; rotateWebhookSecret=true để đổi webhook secret", parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /admin/keys/{id}/webhook/test:
    post: { tags: [Quản trị], summary: Gửi sự kiện ping tới webhook, parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: Kết quả gửi } } }
  /admin/keys/{id}/deliveries:
    get: { tags: [Quản trị], summary: Nhật ký gửi webhook (50 lần gần nhất), parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /.well-known/openid-configuration:
    servers: [{ url: / }]
    get: { tags: [OIDC], summary: OIDC discovery, responses: { '200': { description: OK } } }
  /.well-known/jwks.json:
    servers: [{ url: / }]
    get: { tags: [OIDC], summary: Khoá công khai RS256 để kiểm id_token, responses: { '200': { description: OK } } }
  /oauth/authorize:
    servers: [{ url: / }]
    get:
      tags: [OIDC]
      summary: Bắt đầu đăng nhập (chuyển tới màn đồng ý /authorize/)
      parameters:
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: client_id, in: query, required: true, schema: { type: string, example: tqc_ab12cd34ef } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string } }
        - { name: scope, in: query, required: true, schema: { type: string, example: openid profile phone offline_access } }
        - { name: state, in: query, schema: { type: string } }
        - { name: nonce, in: query, schema: { type: string } }
        - { name: code_challenge, in: query, required: true, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
      responses: { '302': { description: "→ /authorize/?req=… ; lỗi cấu hình → /authorize/?error=… ; lỗi khác → redirect_uri?error=…&state=…" } }
  /oauth/token:
    servers: [{ url: / }]
    post:
      tags: [OIDC]
      summary: Đổi code (PKCE) hoặc refresh_token lấy token
      requestBody: { required: true, content: { application/x-www-form-urlencoded: { schema: { type: object, properties: { grant_type: { type: string, enum: [authorization_code, refresh_token] }, code: { type: string }, redirect_uri: { type: string }, code_verifier: { type: string }, refresh_token: { type: string }, client_id: { type: string }, client_secret: { type: string } } } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { access_token: tqo_…, token_type: Bearer, expires_in: 900, scope: openid profile offline_access, id_token: eyJ…, refresh_token: tqrt_… } } } }
        '400': { description: "invalid_grant | unsupported_grant_type", content: { application/json: { example: { error: invalid_grant, error_description: … } } } }
        '401': { description: invalid_client }
  /oauth/userinfo:
    servers: [{ url: / }]
    get: { tags: [OIDC], summary: Thông tin người dùng theo scope đã cho phép, security: [{ oauthBearer: [] }], responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CoreProfile' } } } }, '401': { description: invalid_token } } }
  /oauth/revoke:
    servers: [{ url: / }]
    post: { tags: [OIDC], summary: Thu hồi refresh token (RFC 7009), responses: { '200': { description: OK } } }

  # ---------- Kết nối dữ liệu nội bộ ----------
  /data/token:
    post:
      tags: [Dữ liệu nội bộ]
      summary: Lấy token client_credentials (15 phút)
      security: [{ dataBasic: [] }, {}]
      requestBody: { required: true, content: { application/x-www-form-urlencoded: { schema: { type: object, required: [grant_type], properties: { grant_type: { type: string, enum: [client_credentials] }, scope: { type: string, description: "Tuỳ chọn, thu hẹp phạm vi (cách nhau dấu cách)" }, client_id: { type: string }, client_secret: { type: string } } } }, application/json: { schema: { type: object } } } }
      responses:
        '200': { description: OK, content: { application/json: { example: { access_token: tqdt_…, token_type: Bearer, expires_in: 900, scope: read:travellers read:events } } } }
        '400': { description: "unsupported_grant_type | invalid_scope | invalid_request" }
        '401': { description: invalid_client }
  /data/me:
    get: { tags: [Dữ liệu nội bộ], summary: Thông tin khoá đang dùng (phạm vi, IP, giới hạn, trạng thái mã hoá), security: [{ dataBasic: [] }, { dataBearer: [] }], responses: { '200': { description: OK }, '401': { $ref: '#/components/responses/Err' } } }
  /data/travellers:
    get:
      tags: [Dữ liệu nội bộ]
      summary: Danh sách / đồng bộ tăng dần (sắp theo thời điểm cập nhật)
      description: "read:travellers → hồ sơ đầy đủ; chỉ read:ids → dạng rút gọn (view=ids). Lưu nextCursor và gọi lại với cursor để nhận bản ghi mới/đổi. Header x-next-cursor."
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      parameters:
        - { name: cursor, in: query, schema: { type: string } }
        - { name: updated_since, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: status, in: query, schema: { type: string, enum: [active, locked] } }
        - { name: kind, in: query, schema: { type: string, enum: [vn, intl] } }
        - { name: demo, in: query, schema: { type: string, enum: [exclude, only] } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/DataTraveller' } }, nextCursor: { type: string, nullable: true }, hasMore: { type: boolean }, view: { type: string, enum: [full, ids] } } } } } }
        '400': { description: "invalid_cursor | invalid_updated_since | invalid_status | invalid_kind" }
        '403': { description: "insufficient_scope | data_key_revoked | ip_not_allowed | browser_not_allowed" }
  /data/travellers/{tqid}:
    get: { tags: [Dữ liệu nội bộ], summary: Hồ sơ theo Travel ID, security: [{ dataBasic: [] }, { dataBearer: [] }], parameters: [{ $ref: '#/components/parameters/tqid' }], responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DataTraveller' } } } }, '404': { $ref: '#/components/responses/Err' } } }
  /data/ids/{tqid}:
    get: { tags: [Dữ liệu nội bộ], summary: Trạng thái ID (không có dữ liệu cá nhân), security: [{ dataBasic: [] }, { dataBearer: [] }], parameters: [{ $ref: '#/components/parameters/tqid' }], responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DataIdStatus' } } } }, '400': { description: invalid_format }, '404': { description: not_found } } }
  /data/ids/verify:
    post: { tags: [Dữ liệu nội bộ], summary: Kiểm tra tối đa 100 Travel ID, security: [{ dataBasic: [] }, { dataBearer: [] }], requestBody: { required: true, content: { application/json: { example: { travelIds: [TQ482019379] } } } }, responses: { '200': { description: "{results:[DataIdStatus + input, reason?]}" } } }
  /data/lookup:
    post:
      tags: [Dữ liệu nội bộ]
      summary: Tra cứu theo travelId / SĐT / CCCD / hộ chiếu (dữ liệu trong thân, không trên URL)
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { travelId: { type: string }, phone: { type: string }, cccd: { type: string }, passport: { type: string } } }, example: { phone: '+84901234567' } } } }
      responses: { '200': { description: "{found, by, items:[DataTraveller | DataIdStatus]}" }, '422': { $ref: '#/components/responses/Err' } }
  /data/accounts/{sub}:
    get: { tags: [Dữ liệu nội bộ], summary: Tài khoản theo sub (kể cả chưa có ID), security: [{ dataBasic: [] }, { dataBearer: [] }], parameters: [{ name: sub, in: path, required: true, schema: { type: string } }], responses: { '200': { description: "{sub, loginType, via, createdAt, hasTravelId, travelId, issuedAt, demo}" }, '404': { $ref: '#/components/responses/Err' } } }
  /data/export:
    get:
      tags: [Dữ liệu nội bộ]
      summary: Xuất hồ sơ (json | ndjson | csv), tối đa 5000 bản ghi mỗi lần
      description: "Header x-tqid-count, x-has-more, x-next-cursor. CSV UTF-8 có BOM; cột documentNumber chỉ có với read:documents_full."
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [json, ndjson, csv], default: json } }
        - { name: cursor, in: query, schema: { type: string } }
        - { name: updated_since, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, maximum: 5000, default: 1000 } }
      responses: { '200': { description: OK, content: { application/json: {}, application/x-ndjson: {}, text/csv: {} } } }
  /data/transactions:
    get:
      tags: [Dữ liệu nội bộ]
      summary: Giao dịch toàn hệ thống (cursor / since / source)
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      parameters:
        - { name: cursor, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: source, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 500, default: 100 } }
      responses: { '200': { description: "{items, nextCursor, hasMore}" } }
    post:
      tags: [Dữ liệu nội bộ]
      summary: Ghi lô giao dịch (≤100), kết quả theo dòng
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      requestBody: { required: true, content: { application/json: { example: { items: [{ travelId: TQ482019379, type: order, ref: OD-1, amount: 120000 }] } } } }
      responses: { '200': { description: "{ok, created, results:[{index, status, ok, idempotent?, transactionId?, error?}]}" } }
  /data/travellers/{tqid}/transactions:
    get: { tags: [Dữ liệu nội bộ], summary: Giao dịch của một ID, security: [{ dataBasic: [] }, { dataBearer: [] }], parameters: [{ $ref: '#/components/parameters/tqid' }], responses: { '200': { description: OK } } }
    post:
      tags: [Dữ liệu nội bộ]
      summary: Ghi giao dịch (idempotent theo ref)
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      parameters: [{ $ref: '#/components/parameters/tqid' }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DataTransactionIn' } } } }
      responses: { '201': { description: Đã ghi }, '200': { description: "Đã có (idempotent)" }, '422': { $ref: '#/components/responses/Err' }, '423': { description: id_locked } }
  /data/events:
    get:
      tags: [Dữ liệu nội bộ]
      summary: Nhật ký sự kiện (đọc bù webhook)
      security: [{ dataBasic: [] }, { dataBearer: [] }]
      parameters:
        - { name: cursor, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: types, in: query, schema: { type: string, example: "id.issued,profile.updated" } }
      responses: { '200': { description: "{items:[{id, type, createdAt, data:{sub, travelId, status, changed?}}], nextCursor, hasMore}" } }
  /admin/data-keys:
    get: { tags: [Quản trị], summary: Danh sách khoá dữ liệu nội bộ + danh mục phạm vi/sự kiện + trạng thái mã hoá, responses: { '200': { description: OK } } }
    post: { tags: [Quản trị], summary: "Tạo khoá dữ liệu (trả clientId, clientSecret, webhookSecret một lần)", requestBody: { content: { application/json: { example: { name: App TravelQ (backend), source: app-travelq, scopes: [read:travellers, read:events], ipAllow: [203.0.113.10], rpm: 300, webhookUrl: 'https://dw.example/hooks/tqid', events: [id.issued] } } } }, responses: { '201': { description: Đã tạo }, '422': { $ref: '#/components/responses/Err' } } }
  /admin/data-keys/{id}:
    patch: { tags: [Quản trị], summary: Sửa khoá (tên, phạm vi, IP, giới hạn, webhook; rotateWebhookSecret), parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /admin/data-keys/{id}/revoke:
    post: { tags: [Quản trị], summary: Thu hồi khoá (token đã cấp hết hiệu lực), parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /admin/data-keys/{id}/rotate:
    post: { tags: [Quản trị], summary: Đổi client secret (secret và token cũ hết hiệu lực), parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
