T5 — Định nghĩa API Contract (GraphQL Schema) - congsinhv/fluxion GitHub Wiki

Định Nghĩa API Contract (GraphQL Schema)

Issue: #28 — Định nghĩa API Contract (GraphQL Schema) Tuần: 5 | 21/04 – 27/04/2026

Numbering chính thức: Mục 3.8 theo Master TOC

Revision 2026-04-19: Follow-up sau audit.

  • Thêm mutation generateIconUploadUrl + type IconUploadUrl (pre-signed S3 PUT cho icon upload — Option A locked)
  • Icon.filePath lưu S3 key (không full URL); FE compose qua CloudFront
  • ActionLog.errorCount → COMPUTED field (không store trong batch_actions). Resolver COUNT từ batch_device_actions
  • ActionLog.status semantics: reflect dispatch status (action-trigger publish SNS xong = COMPLETED), không track device execution callback
  • Naming fix: các wiki khác đồng bộ sang assignAction/assignBulkAction (match live schema)

Revision 2026-04-18: Bổ sung sau khi review wireframes React Dashboard (T8b).

  • 2 enums mới: ActionLogStatus, NotificationType
  • 7 types mới: Brand, TAC, TACConnection, Icon, MessageTemplateIcon, ActionLog, ActionLogConnection, ActionLogErrorReport, ChatSessionConnection
  • MessageTemplate overhaul: localenotificationType (FULLSCREEN/POPUP) + icons (3 slots) + isActive soft delete
  • AssignActionInput + BulkAssignInput thêm messageTemplateId (optional)
  • listChatSessions breaking: return ChatSessionConnection! thay vì [ChatSession!]! (consistency với các Connection types)
  • 6 queries mới + 4 mutations mới (TAC CRUD, ActionLog, S3 error report)
  • Tracking issues: #55–#60

3.8 Định Nghĩa API Contract (GraphQL Schema)

3.8.1 Overview

Fluxion sử dụng AWS AppSync làm unified GraphQL API. Mọi giao tiếp giữa UI ↔ BE đi qua AppSync — bao gồm cả chatbot (không có REST endpoint riêng).

CQRS mapping:

  • Queries (read) → device-resolver, user-resolver, platform-resolver, chat-resolver
  • Mutations (write) → action-resolver, upload-resolver, chat-resolver, user-resolver, platform-resolver
  • Subscriptions (real-time) → AppSync built-in, triggered by checkin-handler

Auth: AWS Cognito JWT — mọi request phải có valid token. Role-based: ADMIN, OPERATOR.


3.8.2 GraphQL Schema

3.8.2.1 Enums

enum UserRole {
  ADMIN
  OPERATOR
}

enum ActionStatus {
  ACTION_PENDING
  ACTION_COMPLETED
  ACTION_FAILED
}

enum ChatMessageRole {
  USER
  ASSISTANT
  TOOL
}

enum ActionLogStatus {
  IN_PROGRESS
  COMPLETED
  FAILED
}

enum NotificationType {
  FULLSCREEN
  POPUP
}

3.8.2.2 Types

# ─── State / Policy / Action (config) ─────────────────

type State {
  id: Int!
  name: String!
}

type Service {
  id: Int!
  name: String!
  isEnabled: Boolean!
}

type Policy {
  id: Int!
  name: String!
  stateId: Int!
  state: State!
  serviceTypeId: Int!
  color: String
}

type Action {
  id: ID!
  name: String!
  actionTypeId: Int!
  fromStateId: Int
  fromState: State
  serviceTypeId: Int
  applyPolicyId: Int!
  applyPolicy: Policy!
  configuration: AWSJSON
}

type Icon {
  filePath: String!         # S3 key (e.g. "message-template-icons/abc.png"); FE compose CDN URL
  name: String!             # Original filename
}

type IconUploadUrl {
  url: String!              # Pre-signed S3 PUT URL
  filePath: String!         # S3 key FE sẽ submit sau khi upload
  expiresAt: AWSDateTime!   # TTL 5 minutes
}

type MessageTemplateIcon {
  notificationIcon: Icon!
  headerIcon: Icon!
  additionalIcon: Icon!
}

type MessageTemplate {
  id: ID!
  name: String!
  content: String!                     # Plain text (NO rendering); used as-is
  notificationType: NotificationType!  # FULLSCREEN | POPUP
  isActive: Boolean                    # Soft delete; default true
  icons: MessageTemplateIcon!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

type MessageTemplateConnection {
  items: [MessageTemplate!]!
  nextToken: String
}

# ─── TAC & Brand (Type Allocation Code) ───────────────

type Brand {
  id: ID!
  name: String!            # "iPhone", "Galaxy", ...
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

type TAC {
  id: ID!
  tac: String!             # 8-digit code; unique (e.g. "35387910")
  provisioningType: String! # Platform vendor: "Apple" | "Android"
  brand: Brand             # Resolved via brand_id FK
  model: String            # Device model code (e.g. "A2650")
  marketingName: String    # Human-readable (e.g. "iPhone 14 Pro")
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

type TACConnection {
  items: [TAC!]!
  nextToken: String
}

# ─── Action Log (batch execution summary) ─────────────

type ActionLog {
  id: ID!
  batchId: ID!             # 1 row per batch (không per-device)
  actionId: ID!
  created_by: String!      # userId
  totalDevices: Int!
  errorCount: Int!         # COMPUTED: COUNT(batch_device_actions WHERE error_code IS NOT NULL) — không store trong batch_actions
  status: ActionLogStatus! # IN_PROGRESS | COMPLETED | FAILED — reflect dispatch status (action-trigger publish SNS xong = COMPLETED)
  created_at: AWSDateTime!
}

type ActionLogConnection {
  items: [ActionLog!]!
  nextToken: String
}

type ActionLogErrorReport {
  batchId: ID!
  url: String!             # Pre-signed S3 URL
  expiresAt: AWSDateTime!  # TTL 5 minutes
}

# ─── Device ────────────────────────────────────────────

type Device {
  id: ID!
  currentPolicy: Policy            # trạng thái hiện tại (state suy từ currentPolicy.stateId)
  lastChanged: DeviceLastChange    # thông tin thay đổi gần nhất
  information: DeviceInformation
  availableActions: [Action!]      # field resolver — chỉ resolve khi client request
  tokens: DeviceToken
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!          # = timestamp of last change
}

type DeviceLastChange {
  assignedActionId: ID
  appliedPolicy: Policy            # policy được apply bởi action cuối
}

type DeviceInformation {
  id: ID!
  deviceId: ID!
  serialNumber: String!
  udid: String!
  name: String
  model: String
  osVersion: String
  batteryLevel: Float
  wifiMac: String
  isSupervised: Boolean
  lastCheckinAt: AWSDateTime
  extFields: AWSJSON
}

type DeviceToken {
  id: ID!
  deviceId: ID!
  topic: String!
  updatedAt: AWSDateTime!
}

type DeviceConnection {
  items: [Device!]!
  nextToken: String
  totalCount: Int
}

# ─── Action Execution ──────────────────────────────────

type ActionExecution {
  id: ID!
  deviceId: ID!
  device: Device
  actionId: ID!
  action: Action
  commandUuid: ID!
  status: ActionStatus!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
  extFields: AWSJSON
}

# ─── Milestone ─────────────────────────────────────────

type Milestone {
  id: ID!
  deviceId: ID!
  assignedActionId: ID
  action: Action
  policyId: Int
  policy: Policy
  createdAt: AWSDateTime!
  extFields: AWSJSON
}

type MilestoneConnection {
  items: [Milestone!]!
  nextToken: String
}

# ─── User ──────────────────────────────────────────────

type User {
  id: ID!
  email: String!
  name: String!
  role: UserRole!
  isActive: Boolean!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

type UserConnection {
  items: [User!]!
  nextToken: String
  totalCount: Int
}

# ─── Chat ──────────────────────────────────────────────

type ChatSession {
  id: ID!
  userId: ID!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
  messages: [ChatMessage!]
}

type ChatMessage {
  id: ID!
  sessionId: ID!
  role: ChatMessageRole!
  content: String
  toolCalls: AWSJSON
  toolResult: AWSJSON
  createdAt: AWSDateTime!
}

type ChatResponse {
  message: ChatMessage!
  session: ChatSession!
}

type ChatSessionConnection {
  items: [ChatSession!]!
  nextToken: String
}

# ─── Upload ────────────────────────────────────────────

type UploadResult {
  totalRequested: Int!
  accepted: Int!
  rejected: Int!
  errors: [UploadError!]
}

type UploadError {
  index: Int!
  serialNumber: String
  reason: String!
}

# ─── Action Response ───────────────────────────────────

type AssignActionResponse {
  executionId: ID!
  commandUuid: ID!
  status: ActionStatus!
}

type BulkAssignResponse {
  valid: [AssignActionResponse!]!
  failed: [BulkAssignError!]!
}

type BulkAssignError {
  deviceId: ID!
  reason: String!
}

3.8.2.3 Inputs

input DeviceFilter {
  stateId: Int
  policyId: Int
  search: String          # search by name, serial, udid
}

input UploadDeviceInput {
  serialNumber: String!
  udid: String!
  name: String
  model: String
  osVersion: String
}

input AssignActionInput {
  deviceId: ID!
  actionId: ID!
  configuration: AWSJSON     # optional: { "Message": "...", "PhoneNumber": "..." }
  messageTemplateId: ID      # optional: nếu set, resolver load template.content → payload
}

input BulkAssignInput {
  deviceIds: [ID!]!
  actionId: ID!
  configuration: AWSJSON
  messageTemplateId: ID      # optional; same behavior
}

input SendChatMessageInput {
  sessionId: ID           # null = new session
  content: String!
}

input CreateUserInput {
  email: String!
  name: String!
  role: UserRole!
}

input UpdateUserInput {
  name: String
  role: UserRole
  isActive: Boolean
}

input UpdateStateInput {
  name: String!
}

input UpdatePolicyInput {
  name: String
  stateId: Int
  serviceTypeId: Int
  color: String
}

input UpdateActionInput {
  name: String
  actionTypeId: Int
  fromStateId: Int
  serviceTypeId: Int
  applyPolicyId: Int
  configuration: AWSJSON
}

input UpdateServiceInput {
  name: String
  isEnabled: Boolean
}

input IconInput {
  filePath: String!
  name: String!
}

input MessageTemplateIconInput {
  notificationIcon: IconInput!
  headerIcon: IconInput!
  additionalIcon: IconInput!
}

input CreateMessageTemplateInput {
  name: String!
  content: String!
  notificationType: NotificationType!
  icons: MessageTemplateIconInput!
}

input UpdateMessageTemplateInput {
  name: String
  content: String
  notificationType: NotificationType
  icons: MessageTemplateIconInput
  isActive: Boolean
}

input CreateTACInput {
  tac: String!
  provisioningType: String!
  brandId: ID
  model: String
  marketingName: String
}

input UpdateTACInput {
  tac: String
  provisioningType: String
  brandId: ID
  model: String
  marketingName: String
}

3.8.2.4 Queries

type Query {
  # ─── device-resolver ──────────────────────────
  getDevice(id: ID!): Device
  listDevices(
    filter: DeviceFilter
    limit: Int = 20
    nextToken: String
  ): DeviceConnection!
  getDeviceHistory(
    deviceId: ID!
    limit: Int = 20
    nextToken: String
  ): MilestoneConnection!

  # ─── platform-resolver (config) ───────────────
  listStates(serviceTypeId: Int): [State!]!
  listPolicies(serviceTypeId: Int): [Policy!]!
  listActions(fromStateId: Int, serviceTypeId: Int): [Action!]!
  listServices: [Service!]!

  # ─── message-template-resolver ────────────────
  getMessageTemplate(id: ID!): MessageTemplate
  listMessageTemplates(
    notificationType: NotificationType
    limit: Int = 20
    nextToken: String
  ): MessageTemplateConnection!

  # ─── tac-resolver ─────────────────────────────
  getTAC(id: ID!): TAC
  listTACs(search: String, limit: Int = 20, nextToken: String): TACConnection!

  # ─── action-log-resolver ──────────────────────
  getActionLog(batchId: ID!): ActionLog
  listActionLogs(limit: Int = 20, nextToken: String): ActionLogConnection!

  # ─── user-resolver ────────────────────────────
  getUser(id: ID!): User
  listUsers(limit: Int = 20, nextToken: String): UserConnection!
  getCurrentUser: User!    # current logged-in user from JWT

  # ─── chat-resolver ────────────────────────────
  getChatSession(id: ID!): ChatSession
  listChatSessions(limit: Int = 10, nextToken: String): ChatSessionConnection!
}

Note: Device.availableActions is a field resolver on device-resolver — nó chỉ được resolve khi client request field này trong query. Dashboard stats (device count by state per service) được tính toán trên frontend từ listDevices.

3.8.2.5 Mutations

type Mutation {
  # ─── action-resolver ──────────────────────────
  assignAction(input: AssignActionInput!): AssignActionResponse!
  assignBulkAction(input: BulkAssignInput!): BulkAssignResponse!

  # ─── platform-resolver (ADMIN only) ───────────
  updateState(id: Int!, input: UpdateStateInput!): State!
  updatePolicy(id: Int!, input: UpdatePolicyInput!): Policy!
  updateAction(id: ID!, input: UpdateActionInput!): Action!
  updateService(id: Int!, input: UpdateServiceInput!): Service!

  # ─── message-template-resolver (ADMIN only) ───
  # Pre-signed S3 PUT URL cho FE upload icon trực tiếp (Option A)
  generateIconUploadUrl(fileName: String!, contentType: String!): IconUploadUrl!
  createMessageTemplate(input: CreateMessageTemplateInput!): MessageTemplate!
  updateMessageTemplate(id: ID!, input: UpdateMessageTemplateInput!): MessageTemplate!
  deleteMessageTemplate(id: ID!): Boolean!   # soft delete (set isActive=false)

  # ─── tac-resolver (ADMIN only) ────────────────
  createTAC(input: CreateTACInput!): TAC!
  updateTAC(id: ID!, input: UpdateTACInput!): TAC!
  deleteTAC(id: ID!): Boolean!

  # ─── action-log-resolver (ADMIN, OPERATOR) ────
  # Generate pre-signed S3 URL để FE download CSV (device_id, error_code, message)
  generateActionLogErrorReport(batchId: ID!): ActionLogErrorReport!

  # ─── upload-resolver (ADMIN only) ─────────────
  uploadDevices(devices: [UploadDeviceInput!]!): UploadResult!

  # ─── user-resolver (ADMIN only) ───────────────
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!

  # ─── chat-resolver ────────────────────────────
  sendChatMessage(input: SendChatMessageInput!): ChatResponse!
}

3.8.2.6 Subscriptions

type Subscription {
  # Device state changed (lock, unlock, release, etc.)
  onDeviceStateChanged(deviceId: ID): Device
    @aws_subscribe(mutations: ["notifyDeviceStateChanged"])

  # Action execution status updated
  onActionExecutionUpdated(deviceId: ID): ActionExecution
    @aws_subscribe(mutations: ["notifyActionExecutionUpdated"])
}

# Internal mutations (called by checkin-handler, not exposed to UI)
extend type Mutation {
  notifyDeviceStateChanged(deviceId: ID!, currentPolicyId: Int!): Device
    @aws_iam   # only BE Lambda can call
  notifyActionExecutionUpdated(deviceId: ID!, executionId: ID!, status: ActionStatus!): ActionExecution
    @aws_iam
}

3.8.3 Resolver Mapping

Resolver Queries Mutations Field Resolvers Auth
device-resolver getDevice, listDevices, getDeviceHistory Device.availableActions, Device.lastChanged ADMIN, OPERATOR
platform-resolver listStates, listPolicies, listActions, listServices updateState, updatePolicy, updateAction, updateService ADMIN (mutations), ADMIN+OPERATOR (queries)
message-template-resolver getMessageTemplate, listMessageTemplates createMessageTemplate, updateMessageTemplate, deleteMessageTemplate ADMIN (mutations), ADMIN+OPERATOR (queries)
tac-resolver getTAC, listTACs createTAC, updateTAC, deleteTAC ADMIN (mutations), ADMIN+OPERATOR (queries)
action-log-resolver getActionLog, listActionLogs generateActionLogErrorReport ADMIN, OPERATOR
user-resolver getUser, listUsers, getCurrentUser createUser, updateUser ADMIN (mutations), ADMIN+OPERATOR (queries)
action-resolver assignAction, assignBulkAction (hỗ trợ messageTemplateId) ADMIN, OPERATOR
upload-resolver uploadDevices ADMIN
chat-resolver getChatSession, listChatSessions sendChatMessage ADMIN, OPERATOR
checkin-handler (internal) notifyDeviceStateChanged, notifyActionExecutionUpdated IAM only

3.8.4 Error Codes

# Error response format (AppSync standard)
{
  "data": null,
  "errors": [
    {
      "message": "Device is busy",
      "errorType": "DEVICE_BUSY",
      "data": { "deviceId": "...", "assignedActionId": "..." }
    }
  ]
}
Error Code HTTP-equiv Description Thrown by
UNAUTHORIZED 401 Invalid/expired JWT token Cognito
FORBIDDEN 403 Role not allowed for operation All resolvers
DEVICE_NOT_FOUND 404 Device ID does not exist device-resolver, action-resolver
USER_NOT_FOUND 404 User ID does not exist user-resolver
SESSION_NOT_FOUND 404 Chat session ID does not exist chat-resolver
TEMPLATE_NOT_FOUND 404 Message template ID does not exist message-template-resolver, action-resolver
TEMPLATE_ARCHIVED 409 Template isActive=false, cannot use action-resolver
TAC_NOT_FOUND 404 TAC ID does not exist tac-resolver
TAC_ALREADY_EXISTS 409 tac column UNIQUE constraint violation tac-resolver
BATCH_NOT_FOUND 404 ActionLog batch ID does not exist action-log-resolver
BATCH_IN_PROGRESS 409 Cannot generate error report khi batch còn IN_PROGRESS action-log-resolver
DEVICE_BUSY 409 Device has pending action (assigned_action_id IS NOT NULL) action-resolver
INVALID_TRANSITION 422 action.from_state_id != device.state_id action-resolver
INVALID_INPUT 400 Missing/invalid input fields All resolvers
UPLOAD_PARTIAL_FAILURE 207 Some devices accepted, some rejected upload-resolver
INTERNAL_ERROR 500 Unexpected server error All resolvers

3.8.5 Auth Flow

Client → Cognito (login) → JWT access token
Client → AppSync (with JWT header)
  → AppSync validates JWT
    → Extract: sub, email, custom:role
      → Resolver checks role against operation
        → Execute or FORBIDDEN

Cognito JWT custom claims:

{
  "sub": "cognito-user-id",
  "email": "[email protected]",
  "custom:role": "ADMIN",
  "exp": 1714000000
}

AppSync auth modes:

  • AMAZON_COGNITO_USER_POOLS — UI requests (queries, mutations)
  • AWS_IAM — internal Lambda-to-AppSync (subscription triggers from checkin-handler)

Kết Luận

GraphQL Schema của Fluxion được thiết kế theo mô hình CQRS mapping rõ ràng: Queries ánh xạ vào read resolvers (device-resolver, platform-resolver, user-resolver), Mutations ánh xạ vào write resolvers (action-resolver, upload-resolver, chat-resolver, user-resolver, platform-resolver), và Subscriptions được triggered nội bộ bởi checkin-handler qua IAM-only mutations. Bảy resolver Lambda có ranh giới trách nhiệm tách biệt, tránh cross-resolver dependencies và cho phép scale độc lập.

Auth flow 2 lớp (Cognito JWT validation tại AppSync + RBAC check tại Lambda) đảm bảo defense-in-depth: AppSync reject request không có valid token trước khi Lambda được invoke, Lambda kiểm tra role claim trước khi thực thi mutation. Internal subscription trigger mutations (notifyDeviceStateChanged, notifyActionExecutionUpdated) được bảo vệ bằng @aws_iam — không có client nào có thể giả mạo subscription events từ bên ngoài.

Error handling theo AppSync standard format (errorType + data) cung cấp 11 error codes có thể phân biệt (UNAUTHORIZED, FORBIDDEN, DEVICE_BUSY, INVALID_TRANSITION, TEMPLATE_NOT_FOUND, v.v.) đủ để client xử lý gracefully từng loại lỗi. Schema sử dụng Connection pattern (DeviceConnection, UserConnection) cho pagination, AWSDateTime/AWSJSON cho scalar types chuẩn AppSync, và input types riêng biệt cho mutations — đảm bảo type safety và backward compatibility khi schema evolve.

Tài Liệu Tham Khảo

[1] GraphQL Foundation. GraphQL Specification. 2021. https://spec.graphql.org/

[2] AWS. AWS AppSync Developer Guide. 2024.

[3] Buna, S. GraphQL in Action. Manning, 2021.

[4] AWS. AppSync Security Best Practices. 2024.