openapi: 3.1.0
info:
  title: CTAS Work API
  version: "2026-09-01"
  description: |
    API đối tác của CTAS Work. Xác thực OAuth 2.0 (client credentials, mã kết nối, authorization code + PKCE, refresh token).
    Mọi id ra ngoài là id công khai có tiền tố (`org_`, `dep_`, `pos_`, `mem_`, `con_`, `app_`). Danh sách phân trang bằng con trỏ.
servers:
  - url: https://api.ctas.vn/api/developer
    description: Production
  - url: http://ctas-api.test/api/developer
    description: Local (Herd)

x-ctas-scopes:
  - organization:read
  - members:read
  - members:write
  - members:salary
  - attendance:read
  - leave:read
  - leave:write
  - process:read
  - process:write
  - identity:write
  - webhooks:manage

tags:
  - name: OAuth
  - name: App
  - name: Work

paths:
  /oauth/token:
    post:
      tags: [OAuth]
      summary: Lấy access token
      description: RFC 6749. `grant_type` là `client_credentials`, `urn:ctas:params:oauth:grant-type:connection_code`, `authorization_code` hoặc `refresh_token`. Client xác thực bằng body hoặc HTTP Basic.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TokenRequest' }
      responses:
        "200":
          description: Token
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        "400":
          description: invalid_request / invalid_grant / invalid_scope / unsupported_grant_type
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
        "401":
          description: invalid_client
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthError' }
  /oauth/revoke:
    post:
      tags: [OAuth]
      summary: Thu hồi access token hoặc refresh token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, client_id, client_secret]
              properties:
                token: { type: string }
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "200": { description: Đã thu hồi (kể cả khi token đã chết) }
        "401": { description: invalid_client }
  /oauth/introspect:
    post:
      tags: [OAuth]
      summary: Kiểm tra một access token của chính client
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, client_id, client_secret]
              properties:
                token: { type: string }
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "200":
          description: RFC 7662
          content:
            application/json:
              schema:
                type: object
                properties:
                  active: { type: boolean }
                  scope: { type: string }
                  connection_id: { type: [string, "null"] }
                  organization_id: { type: [string, "null"] }
                  exp: { type: integer }

  /v1/app:
    get:
      tags: [App]
      summary: Token này là của ứng dụng nào, môi trường nào, kết nối nào
      security: [{ bearer: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, example: app_7hk2m9q4dsxz1p8fjt3v }
                      name: { type: string }
                      type: { type: string, enum: [internal, platform] }
                      environment: { type: string, enum: [test, live] }
                      api_version: { type: string }
                      scopes: { type: array, items: { type: string } }
                      connection: { type: [object, "null"] }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /v1/work/organization:
    get:
      tags: [Work]
      summary: Tổ chức của kết nối
      x-ctas-scope: organization:read
      security: [{ bearer: [organization:read] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Organization' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }

  /v1/work/departments:
    get:
      tags: [Work]
      summary: Phòng ban / cửa hàng
      x-ctas-scope: organization:read
      security: [{ bearer: [organization:read] }]
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Department' } }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
  /v1/work/departments/{department}:
    get:
      tags: [Work]
      summary: Một phòng ban
      x-ctas-scope: organization:read
      security: [{ bearer: [organization:read] }]
      parameters:
        - { name: department, in: path, required: true, schema: { type: string, example: dep_2hs8k1m4n7p0q3r6t9vx } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Department' }
        "404": { $ref: '#/components/responses/NotFound' }

  /v1/work/positions:
    get:
      tags: [Work]
      summary: Chức danh
      x-ctas-scope: organization:read
      security: [{ bearer: [organization:read] }]
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Position' } }
                  meta: { $ref: '#/components/schemas/CursorMeta' }

  /v1/work/members:
    get:
      tags: [Work]
      summary: Danh sách nhân sự
      description: Thêm các trường lương khi token có `members:salary`. Không bao giờ trả số điện thoại, email hay id tài khoản.
      x-ctas-scope: members:read
      security: [{ bearer: [members:read] }]
      parameters:
        - { name: department, in: query, schema: { type: string }, description: id công khai `dep_…` }
        - { name: status, in: query, schema: { type: string, enum: [active, paused, left] } }
        - { name: q, in: query, schema: { type: string, maxLength: 80 }, description: tìm theo tên }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Member' } }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
    post:
      tags: [Work]
      summary: Thêm nhân sự
      description: Số điện thoại là khoá tài khoản. Người chưa có tài khoản CTAS được tạo ở trạng thái chờ kích hoạt, `meta.activation_code` để chuyển cho họ. Không tạo được vai trò có quyền quản trị. Trường lương cần thêm `members:salary`.
      x-ctas-scope: members:write
      security: [{ bearer: [members:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, phone]
              properties:
                name: { type: string, maxLength: 255 }
                phone: { type: string, maxLength: 20 }
                department_id: { type: [string, "null"] }
                position_id: { type: [string, "null"] }
                role: { type: string, default: staff }
                join_date: { type: string, format: date }
                base_salary: { type: integer }
                salary_basis: { type: string, enum: [month, day, hour] }
                rate: { type: integer }
      responses:
        "201":
          description: Đã tạo
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Member' }
                  meta:
                    type: object
                    properties:
                      activation_code: { type: [string, "null"] }
        "403": { description: PLAN_LIMIT — hết chỗ theo gói; INSUFFICIENT_SCOPE — trường lương không có members:salary }
        "409": { description: CONFLICT — số điện thoại đã là nhân sự }
        "422": { $ref: '#/components/responses/Unprocessable' }

  /v1/work/attendance:
    get:
      tags: [Work]
      summary: Chấm công theo khoảng ngày
      description: Tối đa 92 ngày mỗi lần gọi. Thứ tự theo thời điểm tạo bản ghi, phân trang cursor.
      x-ctas-scope: attendance:read
      security: [{ bearer: [attendance:read] }]
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
        - { name: member, in: query, schema: { type: string } }
        - { name: department, in: query, schema: { type: string } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Attendance' } }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        "422": { $ref: '#/components/responses/Unprocessable' }

  /v1/work/schedule:
    get:
      tags: [Work]
      summary: Lịch làm việc của một nhân sự
      description: Một nhân sự mỗi lần gọi, tối đa 92 ngày. Gồm cả ca xếp tay lẫn ca cố định.
      x-ctas-scope: attendance:read
      security: [{ bearer: [attendance:read] }]
      parameters:
        - { name: member, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ScheduleDay' } }
                  meta:
                    type: object
                    properties:
                      member_id: { type: string }
                      from: { type: string, format: date }
                      to: { type: string, format: date }
        "404": { $ref: '#/components/responses/NotFound' }
        "422": { $ref: '#/components/responses/Unprocessable' }
  /v1/work/members/{member}:
    get:
      tags: [Work]
      summary: Một nhân sự
      x-ctas-scope: members:read
      security: [{ bearer: [members:read] }]
      parameters:
        - { name: member, in: path, required: true, schema: { type: string, example: mem_kd7p2x9m4n1q8r5t0vwz } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Member' }
        "404": { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Work]
      summary: Cập nhật nhân sự
      x-ctas-scope: members:write
      security: [{ bearer: [members:write] }]
      parameters:
        - { name: member, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                department_id: { type: [string, "null"] }
                position_id: { type: [string, "null"] }
                role: { type: string }
                join_date: { type: string, format: date }
                base_salary: { type: integer }
                salary_basis: { type: string, enum: [month, day, hour] }
                rate: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Member' }
        "403": { description: ADMIN_PROTECTED — không sửa được quản trị viên }
        "404": { $ref: '#/components/responses/NotFound' }
  /v1/work/members/{member}/deactivate:
    post:
      tags: [Work]
      summary: Cho nghỉ việc (status → left)
      description: CTAS Work không xoá nhân sự — lương và chấm công phải giữ để đối chiếu.
      x-ctas-scope: members:write
      security: [{ bearer: [members:write] }]
      parameters:
        - { name: member, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                effective_date: { type: string, format: date }
                reason: { type: string, maxLength: 200 }
      responses:
        "200": { description: OK }
        "403": { description: ADMIN_PROTECTED }
        "409": { description: CONFLICT — đã nghỉ }
  /v1/work/members/{member}/activation-code:
    post:
      tags: [Work]
      summary: Cấp lại mã kích hoạt cho nhân sự chưa mở app
      x-ctas-scope: members:write
      security: [{ bearer: [members:write] }]
      parameters:
        - { name: member, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      activation_code: { type: string }
                      expires_in_days: { type: integer }
        "409": { description: CONFLICT — tài khoản đã kích hoạt }

  /v1/work/leave-requests:
    get:
      tags: [Work]
      summary: Đơn nghỉ
      x-ctas-scope: leave:read
      security: [{ bearer: [leave:read] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [pending, approved, rejected, canceled] } }
        - { name: member, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/LeaveRequest' } }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
  /v1/work/leave-requests/{leaveRequest}/approve:
    post:
      tags: [Work]
      summary: Duyệt đơn nghỉ
      x-ctas-scope: leave:write
      security: [{ bearer: [leave:write] }]
      parameters:
        - { name: leaveRequest, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { note: { type: string, maxLength: 200 } } }
      responses:
        "200": { description: OK }
        "409": { description: CONFLICT — đã được quyết định }
  /v1/work/leave-requests/{leaveRequest}/reject:
    post:
      tags: [Work]
      summary: Từ chối đơn nghỉ
      x-ctas-scope: leave:write
      security: [{ bearer: [leave:write] }]
      parameters:
        - { name: leaveRequest, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string, maxLength: 200 } } }
      responses:
        "200": { description: OK }
        "409": { description: CONFLICT — đã được quyết định }

  /v1/work/process/forms:
    get:
      tags: [Work]
      summary: Biểu mẫu quy trình
      x-ctas-scope: process:read
      security: [{ bearer: [process:read] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [draft, active, archived] } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200": { description: OK }
  /v1/work/process/assignments:
    get:
      tags: [Work]
      summary: Phân công quy trình
      x-ctas-scope: process:read
      security: [{ bearer: [process:read] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [active, paused, ended] } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200": { description: OK }
  /v1/work/process/assignments/{assignment}/trigger:
    post:
      tags: [Work]
      summary: Kích hoạt một lượt việc ngoài chu kỳ, mồi sẵn dữ liệu
      description: Cùng `external_ref` gửi hai lần là cùng một lượt (200 thay vì 201). `context` được đổ vào biểu mẫu cho nhân viên xác nhận.
      x-ctas-scope: process:write
      security: [{ bearer: [process:write] }]
      parameters:
        - { name: assignment, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                external_ref: { type: string, maxLength: 120 }
                scope:
                  type: object
                  properties:
                    type: { type: string, enum: [org, department, member] }
                    id: { type: string, description: "dep_… hoặc mem_…" }
                due_at: { type: string, format: date-time }
                context: { type: object }
      responses:
        "201": { description: Lượt mới }
        "200": { description: Lượt đã tồn tại (cùng external_ref) }
        "422": { $ref: '#/components/responses/Unprocessable' }
  /v1/work/process/submissions:
    get:
      tags: [Work]
      summary: Phiếu đã nộp
      x-ctas-scope: process:read
      security: [{ bearer: [process:read] }]
      parameters:
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: assignment, in: query, schema: { type: string } }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200": { description: OK }
  /v1/work/process/submissions/{submission}:
    get:
      tags: [Work]
      summary: Một phiếu, đủ câu trả lời và ảnh (URL ký hạn)
      x-ctas-scope: process:read
      security: [{ bearer: [process:read] }]
      parameters:
        - { name: submission, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: OK }
        "404": { $ref: '#/components/responses/NotFound' }

  /v1/work/identity-requests:
    post:
      tags: [Work]
      summary: Tạo yêu cầu định danh bằng QR
      description: Trả `qr_text` một lần duy nhất. Nhân sự quét bằng CTAS Work; bạn nhận `identity.confirmed` qua webhook hoặc poll `GET`.
      x-ctas-scope: identity:write
      security: [{ bearer: [identity:write] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reference: { type: string, maxLength: 120, description: "chuỗi của bạn, trả lại nguyên vẹn" }
                ttl_seconds: { type: integer, minimum: 30, maximum: 900, default: 300 }
                constraints:
                  type: object
                  properties:
                    department_ids: { type: array, items: { type: string }, maxItems: 50 }
                    member_ids: { type: array, items: { type: string }, maxItems: 200 }
      responses:
        "201":
          description: Đã tạo
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - { $ref: '#/components/schemas/IdentityRequest' }
                      - type: object
                        properties:
                          qr_text: { type: string, example: "https://developer.ctas.vn/i/8fJ2kQ…" }
        "422": { $ref: '#/components/responses/Unprocessable' }
        "429": { description: TOO_MANY_PENDING — tối đa 200 yêu cầu đang chờ mỗi kết nối }
  /v1/work/identity-requests/{identityRequest}:
    get:
      tags: [Work]
      summary: Trạng thái một yêu cầu định danh
      x-ctas-scope: identity:write
      security: [{ bearer: [identity:write] }]
      parameters:
        - { name: identityRequest, in: path, required: true, schema: { type: string, example: idr_… } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/IdentityRequest' }
        "404": { $ref: '#/components/responses/NotFound' }
  /v1/work/identity-requests/{identityRequest}/cancel:
    post:
      tags: [Work]
      summary: Huỷ yêu cầu chưa được quét
      x-ctas-scope: identity:write
      security: [{ bearer: [identity:write] }]
      parameters:
        - { name: identityRequest, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Đã huỷ (hoặc đã kết thúc trước đó) }
        "404": { $ref: '#/components/responses/NotFound' }

  /v1/events:
    get:
      tags: [App]
      summary: Sổ sự kiện 30 ngày
      description: Token ứng dụng thấy mọi sự kiện của môi trường; token kết nối chỉ thấy sự kiện của kết nối đó. Cùng payload với webhook.
      security: [{ bearer: [] }]
      parameters:
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: type, in: query, schema: { type: string } }
        - { name: connection, in: query, schema: { type: string }, description: "chỉ với token ứng dụng" }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Event' } }
                  meta: { $ref: '#/components/schemas/CursorMeta' }

  /v1/webhooks:
    get:
      tags: [App]
      summary: Endpoint webhook của môi trường này
      x-ctas-scope: webhooks:manage
      security: [{ bearer: [webhooks:manage] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
    post:
      tags: [App]
      summary: Đăng ký endpoint (secret hiện một lần)
      x-ctas-scope: webhooks:manage
      security: [{ bearer: [webhooks:manage] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, description: "https bắt buộc ở live; không địa chỉ nội bộ" }
                events: { type: array, items: { type: string }, description: "rỗng = mọi sự kiện" }
                description: { type: string, maxLength: 200 }
      responses:
        "201":
          description: "Đã tạo — secret chỉ có trong response này"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - { $ref: '#/components/schemas/Webhook' }
                      - type: object
                        properties:
                          secret: { type: string, example: whsec_… }
        "422": { $ref: '#/components/responses/Unprocessable' }
  /v1/webhooks/{webhook}:
    delete:
      tags: [App]
      summary: Xoá endpoint
      x-ctas-scope: webhooks:manage
      security: [{ bearer: [webhooks:manage] }]
      parameters:
        - { name: webhook, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Đã xoá }
        "404": { $ref: '#/components/responses/NotFound' }
  /v1/webhooks/{webhook}/rotate:
    post:
      tags: [App]
      summary: Xoay secret (secret cũ chết ngay)
      x-ctas-scope: webhooks:manage
      security: [{ bearer: [webhooks:manage] }]
      parameters:
        - { name: webhook, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "Secret mới, hiện một lần" }
  /v1/webhooks/{webhook}/test:
    post:
      tags: [App]
      summary: Gửi sự kiện `webhook.test` ngay và trả kết quả
      x-ctas-scope: webhooks:manage
      security: [{ bearer: [webhooks:manage] }]
      parameters:
        - { name: webhook, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Kết quả lần giao
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Delivery' }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: ctas_at_…
  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
      description: Giá trị `meta.next_cursor` của trang trước.
  responses:
    Unauthorized:
      description: INVALID_TOKEN / TOKEN_EXPIRED
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: INSUFFICIENT_SCOPE / CONNECTION_REQUIRED / CONNECTION_REVOKED / PLAN_REQUIRED
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: NOT_FOUND — không có, hoặc thuộc tổ chức khác
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unprocessable:
      description: VALIDATION_FAILED / UNKNOWN_FIELD / RANGE_TOO_LONG / INVALID_CURSOR
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    LeaveRequest:
      type: object
      properties:
        id: { type: string, example: lvr_… }
        member_id: { type: string }
        department_id: { type: [string, "null"] }
        type: { type: string }
        from_date: { type: string, format: date }
        to_date: { type: string, format: date }
        days: { type: number }
        hours: { type: [number, "null"] }
        half_day_part: { type: [string, "null"] }
        reason: { type: [string, "null"] }
        status: { type: string, enum: [pending, approved, rejected, canceled] }
        decided_via: { type: [string, "null"] }
        decision_note: { type: [string, "null"] }
    IdentityRequest:
      type: object
      properties:
        id: { type: string, example: idr_… }
        reference: { type: [string, "null"] }
        status: { type: string, enum: [pending, confirmed, declined, expired, canceled] }
        environment: { type: string, enum: [test, live] }
        constraints:
          type: object
          properties:
            department_ids: { type: array, items: { type: string } }
            member_ids: { type: array, items: { type: string } }
        expires_at: { type: string, format: date-time }
        confirmed_at: { type: [string, "null"], format: date-time }
        member_id: { type: [string, "null"], description: "mem_… khi confirmed — và chỉ có vậy" }
        department_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    Event:
      type: object
      properties:
        id: { type: string, example: evt_… }
        type: { type: string, example: connection.created }
        api_version: { type: string }
        environment: { type: string, enum: [test, live] }
        created_at: { type: string, format: date-time }
        connection_id: { type: [string, "null"] }
        organization_id: { type: [string, "null"] }
        data: { type: object }
    Webhook:
      type: object
      properties:
        id: { type: string, example: whk_… }
        environment: { type: string, enum: [test, live] }
        url: { type: string }
        events: { type: array, items: { type: string } }
        subscribes_to_everything: { type: boolean }
        description: { type: [string, "null"] }
        status: { type: string, enum: [active, disabled] }
        consecutive_failures: { type: integer }
        last_delivered_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
    Delivery:
      type: object
      properties:
        id: { type: string, example: dlv_… }
        event_id: { type: string }
        event_key: { type: string, description: "giá trị X-CTAS-Event-Id, không đổi giữa các lần thử" }
        event_type: { type: string }
        status: { type: string, enum: [pending, sent, failed] }
        attempts: { type: integer }
        response_status: { type: [integer, "null"] }
        last_error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
    Error:
      type: object
      properties:
        error:
          type: object
          required: [code, message, retryable]
          properties:
            code: { type: string }
            message: { type: string }
            retryable: { type: boolean }
            details: { type: object }
            request_id: { type: string }
    OAuthError:
      type: object
      properties:
        error: { type: string }
        error_description: { type: string }
        reason: { type: string, description: "Có với invalid_grant từ mã kết nối: lý do chính sách." }
    TokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type:
          type: string
          enum: [client_credentials, "urn:ctas:params:oauth:grant-type:connection_code", authorization_code, refresh_token]
        client_id: { type: string }
        client_secret: { type: string }
        scope: { type: string, description: "Cách nhau bằng dấu cách; chỉ được hẹp hơn." }
        code: { type: string, description: "connection_code hoặc authorization_code" }
        code_verifier: { type: string, description: "authorization_code (PKCE S256)" }
        redirect_uri: { type: string, description: "authorization_code" }
        refresh_token: { type: string }
    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, example: 3600 }
        scope: { type: string }
        refresh_token: { type: string }
        refresh_expires_in: { type: integer }
        connection:
          type: [object, "null"]
          properties:
            id: { type: string }
            organization_id: { type: string }
            environment: { type: string, enum: [test, live] }
    CursorMeta:
      type: object
      properties:
        next_cursor: { type: [string, "null"] }
        has_more: { type: boolean }
    Organization:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        environment: { type: string, enum: [test, live] }
        kind: { type: string, enum: [standard, sandbox] }
        currency_code: { type: string }
        timezone: { type: string }
        pay_cycle: { type: [string, "null"] }
        cutoff_day: {}
        payday: {}
        work_basis: { type: [string, "null"] }
        standard_days_per_month: { type: [integer, "null"] }
    Department:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        kind: { type: string }
        address: { type: [string, "null"] }
        lat: { type: [number, "null"] }
        lng: { type: [number, "null"] }
        radius_m: { type: [integer, "null"] }
        lead_member_id: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
    Position:
      type: object
      properties:
        id: { type: string }
        code: { type: string }
        name: { type: string }
        description: { type: [string, "null"] }
        department_id: { type: [string, "null"] }
        level: {}
        status: { type: string }
    Member:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        department_id: { type: [string, "null"] }
        position_id: { type: [string, "null"] }
        position: { type: [string, "null"] }
        role: { type: string }
        status: { type: string, enum: [active, paused, left] }
        join_date: { type: [string, "null"], format: date }
        awaits_activation: { type: boolean }
        base_salary: { type: [integer, "null"], description: "chỉ với members:salary" }
        salary_basis: { type: [string, "null"], description: "chỉ với members:salary" }
        rate: { type: [integer, "null"], description: "chỉ với members:salary" }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
    Shift:
      type: [object, "null"]
      properties:
        name: { type: [string, "null"] }
        start: { type: [string, "null"], example: "08:00" }
        end: { type: [string, "null"], example: "17:00" }
        kind: { type: string }
        standing: { type: boolean, description: "true khi là ca cố định, không phải xếp tay" }
        credit_day: { type: boolean }
    Attendance:
      type: object
      properties:
        id: { type: string }
        member_id: { type: string }
        department_id: { type: [string, "null"] }
        date: { type: string, format: date }
        status: { type: [string, "null"], enum: [present, late, leave, null] }
        check_in: { type: [string, "null"], format: date-time }
        check_out: { type: [string, "null"], format: date-time }
        sessions:
          type: array
          items:
            type: object
            properties:
              check_in: { type: [string, "null"] }
              check_out: { type: [string, "null"] }
              verified_by: { type: string }
        worked_minutes: { type: integer }
        late_minutes: { type: [integer, "null"], description: "null khi không có ca để so" }
        early_leave_minutes: { type: [integer, "null"] }
        leave_type: { type: [string, "null"] }
        shift: { $ref: '#/components/schemas/Shift' }
    ScheduleDay:
      type: object
      properties:
        date: { type: string, format: date }
        is_working_day: { type: boolean }
        shift: { $ref: '#/components/schemas/Shift' }
