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+ typeIconUploadUrl(pre-signed S3 PUT cho icon upload — Option A locked)Icon.filePathlưu S3 key (không full URL); FE compose qua CloudFrontActionLog.errorCount→ COMPUTED field (không store trong batch_actions). Resolver COUNT từbatch_device_actionsActionLog.statussemantics: 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,ChatSessionConnectionMessageTemplateoverhaul:locale→notificationType(FULLSCREEN/POPUP) +icons(3 slots) +isActivesoft deleteAssignActionInput+BulkAssignInputthêmmessageTemplateId(optional)listChatSessionsbreaking: returnChatSessionConnection!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.availableActionsis a field resolver ondevice-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 fromcheckin-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.