08 — FE Onboarding Flow — API Contract & Sequence
Flow ที่ FE ต้องเรียกตั้งแต่ต้นจนจบ อ้างอิงจาก OnboardingRunnerComponent จริง: entry point decision → start → step loop → OTP → complete (BE PR #976, SA-1557)
อัปเดต: 2026-06-30
เอกสารนี้สำหรับ FE developer ที่ implement ส่วน onboarding — อ่านแล้วรู้ว่าต้องเรียก API อะไร ส่งอะไร รับอะไร ในแต่ละขั้นตอน
อ้างอิงจาก code จริง:
OnboardingRunnerComponent.ngOnInit+CorporateApplicationHttpServiceBE branch:
feature/sprint9/SA-1557→development· PR #976 (UserService)
ภาพรวม: Entry Decision Tree
Runner มี 5 entry path — ตัดสินตามลำดับความสำคัญ:
flowchart TD
A[ngOnInit] --> AV{?adminView=}
AV -->|มี| Z1[enterAdminView — read-only step map]
AV -->|ไม่มี| ID
ID{?id=}
ID -->|มี| Z2["POST /instances/{id}/open → routeResume"]
ID -->|ไม่มี| CODE
CODE{?code= invite}
CODE -->|มี| C["check GET /onboarding/session\n(กัน duplicate draft)"]
C -->|200 nav + flowCode เดียวกัน| Z3[resume session เดิม]
C -->|404 หรือ flowCode ต่าง| CINV["POST /validate-invite → startFlow(flowCode,email,code)"]
CODE -->|ไม่มี| FLC
FLC{?flowCode=}
FLC -->|มี| RA["resolveFlowAccess(flowCode) → /access"]
RA -->|SESSION_READY\n+ authed + RETURNING| RR["startReturningRequestor()\n→ POST /requestor/start"]
RA -->|SESSION_READY\nอื่น| EE[email-entry → startFlow]
RA -->|REQUIRES_LOGIN| LOGIN[redirect login]
RA -->|NOT_ALLOWED| ERR[error view]
FLC -->|ไม่มี| AUTH
AUTH{isAuthenticated?}
AUTH -->|ใช่| Z4[redirect /corporate-workspace]
AUTH -->|ไม่ใช่| SES["GET /onboarding/session"]
SES -->|200| Z5[resume session cookie]
SES -->|404| Z6["email-entry → startFlow(STANDARD_CUSTOMER_ONBOARDING, email)"]
Quick Reference — Endpoints ทั้งหมด
| Method | Path | ใช้เมื่อ |
|---|---|---|
GET | /onboarding/session | check/resume cookie session |
POST | /onboarding/instances/{id}/open | resume ด้วย ?id= param |
POST | /onboarding/validate-invite | resolve ?code= invite token |
POST | /onboarding/flows/{flowCode}/access | เฉพาะ ?flowCode= path — ขอ verdict |
POST | /onboarding/requestor/start | start requestor (authed) |
POST | /onboarding/instances | สร้าง FlowInstance (anonymous/invite) |
POST | /onboarding/instances/{id}/steps/{type}/actions/{code} | ส่ง step action |
Base path: /api/user-service/v1
/accessendpoint เรียกเฉพาะ?flowCode=path เท่านั้น — invite path (?code=) และ no-param path ไม่เรียก
Path A — Self-start ผ่าน ?flowCode= (NEW_REQUESTOR, anonymous)
ผู้ใช้ยังไม่ login เข้า URL ที่มี ?flowCode=NEW_REQUESTOR
sequenceDiagram
participant FE
participant BE as UserService
FE->>BE: POST /flows/NEW_REQUESTOR/access {}
BE-->>FE: { decision:"SESSION_READY", effectiveFlowCode:"NEW_REQUESTOR" }
Note over FE: แสดง email-entry form (FE ยังไม่รู้ email ของ anonymous user)
Note over FE: user กรอก email แล้วกด start
FE->>BE: POST /instances { flowCode:"NEW_REQUESTOR", email:"user@example.com" }
BE-->>FE: { nav, editSessionId, sessionTtlSeconds }
Note over FE: set cookie __Host-onboarding=editSessionId
Note over FE,BE: nav.currentStep.type = OtpVerificationStep → auto-send
FE->>BE: POST …/OtpVerificationStep/actions/SaveDraft { stepData:{email} }
BE-->>FE: nav (outputJson มี refCode)
Note over FE: user กรอก OTP
FE->>BE: POST …/OtpVerificationStep/actions/Next { stepData:{otp,refCode} }
BE-->>FE: nav (step ถัดไป)
loop step loop
FE->>BE: POST …/{stepType}/actions/{code} { stepData:{…} }
BE-->>FE: nav
end
ลำดับ API calls
A-0: Pre-flight (?flowCode= ทริกเกอร์)
POST /onboarding/flows/NEW_REQUESTOR/access
body: {}
→ { decision:"SESSION_READY", effectiveFlowCode:"NEW_REQUESTOR" }
สิ่งที่เปลี่ยนจาก spec เดิม:
- field ชื่อ
inviteCode(เดิมชื่อcode) — ส่งเฉพาะ flow ที่มี invite- ไม่ส่ง
SESSION_READY (anonymous, ไม่ใช่ RETURNING) → FE แสดง email-entry form ก่อน
A-1: User กรอก email → สร้าง FlowInstance
POST /onboarding/instances
body: { "flowCode": "NEW_REQUESTOR", "email": "user@example.com" }
→ { nav, editSessionId, sessionTtlSeconds }
FE: set cookie __Host-onboarding=<editSessionId>; SameSite=Strict; Secure; Max-Age=<sessionTtlSeconds>
A-2: Auto-send OTP (OTP = step แรก → applyNav trigger sendOtp(false) อัตโนมัติ)
POST /onboarding/instances/{id}/steps/OtpVerificationStep/actions/SaveDraft
body: { "stepData": { "email": "user@example.com" } }
→ nav { outputJson: '{"refCode":"R1","status":"OTP_SENT","expirySeconds":300}' }
FE: เก็บ refCode จาก outputJson สำหรับ verify · เริ่ม countdown 300 วินาที
A-3: Verify OTP
POST /onboarding/instances/{id}/steps/OtpVerificationStep/actions/Next
body: { "stepData": { "otp": "123456", "refCode": "R1" } }
→ nav (step ถัดไป) หรือ error
dup-email guard อยู่ที่นี่: ถ้า email ซ้ำ (คนอื่น) →
Otp.DuplicateEmail
A-4+: Step Loop (ดู §Phase 2 ด้านล่าง)
Path B — Self-start ผ่าน ?flowCode= (RETURNING_REQUESTOR, login แล้ว)
ผู้ใช้ login แล้ว เข้า URL ที่มี ?flowCode=NEW_REQUESTOR → BE สลับเป็น RETURNING_REQUESTOR
sequenceDiagram
participant FE
participant BE as UserService
FE->>BE: POST /flows/NEW_REQUESTOR/access {}
BE-->>FE: { decision:"SESSION_READY", effectiveFlowCode:"RETURNING_REQUESTOR" }
Note over FE: authed + effectiveFlowCode===RETURNING → startReturningRequestor()
FE->>BE: POST /requestor/start { email:"user@company.com" }
BE-->>FE: { type:"SESSION_READY", flowInstanceId, nav }
Note over FE: navigate ?id=flowInstanceId → routeResume(nav)
loop step loop
FE->>BE: POST …/{stepType}/actions/{code} { stepData:{…} }
BE-->>FE: nav
end
ลำดับ API calls
B-0: Pre-flight (เหมือน A)
POST /onboarding/flows/NEW_REQUESTOR/access
body: {}
→ { decision:"SESSION_READY", effectiveFlowCode:"RETURNING_REQUESTOR" }
B-1: Start requestor (ไม่ต้องสร้าง FlowInstance ใหม่แยก — BE จัดการ new/returning swap เอง)
POST /onboarding/requestor/start
body: { "email": "<auth.userEmail()>" } ← FE อ่านจาก JWT/auth service ฝั่ง FE
→ { type:"SESSION_READY", flowCode:"RETURNING_REQUESTOR", flowInstanceId:"...", nav:{…} }
FE: navigate ?id=flowInstanceId → routeResume(nav) → render step โดยตรง ไม่มี OTP step
B-2+: Step Loop (ดู §Phase 2)
ไม่มี cookie
__Host-onboardingสำหรับ returning requestor — auth ผ่าน JWT แทน
Path C — Customer via Invite Link (?code=inv_xxx)
ลูกค้าได้รับลิงก์เชิญ ต้องเป็น anonymous (ไม่ login)
ไม่เรียก /access — resolveInvite → startFlow ตรงๆ
sequenceDiagram
participant FE
participant BE as UserService
Note over FE: URL: /onboarding?code=inv_abc123
FE->>BE: GET /onboarding/session (check cookie session ก่อน)
BE-->>FE: 404 (ไม่มี session) หรือ 200 (session คนละ flow)
FE->>BE: POST /validate-invite { code:"inv_abc123" }
BE-->>FE: { status:"VALID", flowCode:"CUSTOMER_FIXED_ONBOARDING", email:"customer@example.com" }
FE->>BE: POST /instances { flowCode:"CUSTOMER_FIXED_ONBOARDING", email:"customer@example.com", code:"inv_abc123" }
BE-->>FE: { nav, editSessionId, sessionTtlSeconds }
Note over FE: set cookie __Host-onboarding=editSessionId · flowEmail ถูก set จาก resolveInvite
Note over FE,BE: OTP step (step แรก)
FE->>BE: POST …/OtpVerificationStep/actions/SaveDraft { stepData:{email} }
BE-->>FE: nav (refCode)
FE->>BE: POST …/OtpVerificationStep/actions/Next { stepData:{otp,refCode} }
BE-->>FE: nav
loop step loop
FE->>BE: POST …/{stepType}/actions/{code} { stepData:{…} }
BE-->>FE: nav
end
ลำดับ API calls
C-0: Session check (กัน duplicate draft ถ้า reload ก่อน verify)
GET /onboarding/session
→ 200 nav + flowCode เดียวกัน → resume session เดิม (ไม่ start ใหม่)
→ 404 หรือ flowCode ต่าง → startFromInvite
C-1: Resolve invite
POST /onboarding/validate-invite
body: { "code": "inv_abc123" }
→ { status:"VALID", flowCode:"CUSTOMER_FIXED_ONBOARDING", email:"customer@example.com" }
C-2: สร้าง FlowInstance (code ไปใน body เพื่อให้ BE consume token)
POST /onboarding/instances
body: {
"flowCode": "CUSTOMER_FIXED_ONBOARDING",
"email": "customer@example.com",
"code": "inv_abc123"
}
→ { nav, editSessionId, sessionTtlSeconds }
FE: set cookie __Host-onboarding=<editSessionId>
C-3+: OTP + step loop (เหมือน A-2/A-3 ต่อไป)
No-param Entry (Standard onboarding)
ผู้ใช้เข้า /onboarding โดยไม่มี query param
| สถานะ | Runner ทำอะไร |
|---|---|
| login แล้ว | redirect ไป /corporate-workspace — ไม่แสดง email-entry |
| ไม่ login + มี cookie | GET /onboarding/session → 200 → resume session เดิม |
| ไม่ login + ไม่มี cookie | GET /onboarding/session → 404 → email-entry → startFlow('STANDARD_CUSTOMER_ONBOARDING', email) |
ไม่มีการเรียก
/accessสำหรับ no-param path
POST /onboarding/instances
body: { "flowCode": "STANDARD_CUSTOMER_ONBOARDING", "email": "user@example.com" }
→ { nav, editSessionId, sessionTtlSeconds }
Phase 2 — Step Loop
pattern เดียวสำหรับทุก step หลัง start:
POST /onboarding/instances/{instanceId}/steps/{stepType}/actions/{actionCode}
Content-Type: application/json
Cookie: __Host-onboarding=<editSessionId> ← anonymous session เท่านั้น
body: { "stepData": { …payload จาก step component… } }
สังเกต: body ห่อด้วย
stepDataเสมอ — ไม่ส่ง payload ตรง
Response StepNavigationV2:
{
"status": "IN_PROGRESS",
"flowInstanceId": "abc-123",
"flowCode": "NEW_REQUESTOR",
"currentStep": {
"type": "PersonalInfoStep",
"index": 2,
"canGoBack": true,
"outputJson": null
},
"availableActions": ["Next", "Back"]
}
nav.status → FE action
status | FE แสดงอะไร |
|---|---|
IN_PROGRESS | render currentStep.type ผ่าน STEP_REGISTRY |
SUBMITTED | navigate ไป /application-result?ref=…&id=… |
FINALIZED | inline result → redirect /home (4 วินาที) |
TERMINAL | inline result + terminalStatus |
Action codes
actionCode | ความหมาย |
|---|---|
Next | advance + ส่งข้อมูล |
Back | ย้อนกลับ |
SaveDraft | save ไม่ advance (ใช้ส่ง OTP) |
Submit | submit สำหรับ review |
Resubmit | ส่งใหม่ (rework path) |
OTP Step — รายละเอียด
OTP เป็น step ปกติใน engine ไม่ใช่ gate แยก
FE behavior เมื่อ applyNav เห็น OtpVerificationStep
- Auto-send ทันที —
sendOtp(false)→SaveDraftโดยไม่รอ user - เก็บ refCode จาก
outputJson→ ส่งกลับตอน Next - Countdown 300 วินาที + resend cooldown 60 วินาที
- Resend — user กดปุ่ม →
sendOtp(true)→SaveDraftซ้ำ
SaveDraft body: { "stepData": { "email": "<flowEmail>" } }
Next body: { "stepData": { "otp": "123456", "refCode": "R1" } }
flowEmailถูก set ก่อน startFlow เสมอ (email-entry form / invite email จาก resolveInvite)
dup-email guard (ตรวจที่ OTP verify ไม่ใช่ /access)
| สถานการณ์ | ผล |
|---|---|
| ไม่มีบัญชี | ผ่าน → สร้าง user ใหม่ |
| บัญชีเดิม + OID เดียวกัน (retry) | ผ่าน (idempotent) |
| บัญชีเดิม + OID ต่างกัน | error 4xx: { errorCode:"Otp.DuplicateEmail" } |
FE: แสดงข้อความ + ปุ่มไป login
Resume Paths
Cookie resume (session ค้าง)
GET /onboarding/session
Cookie: __Host-onboarding=<editSessionId>
→ 200: nav → applyNav → routeView
→ 404: ไม่มี session → email-entry
Deep link resume (?id=)
POST /onboarding/instances/{id}/open
body: {}
→ nav → routeResume
→ ถ้า nav.correctionContext → enterReworkView (editable map)
→ ไม่งั้น → applyNav → routeView
Cookie — __Host-onboarding
ใช้เฉพาะ anonymous session (Path A, C, no-param) — returning requestor (Path B) ใช้ JWT
| Attribute | ค่า |
|---|---|
| Name | __Host-onboarding |
| Value | editSessionId (จาก BE response) |
| SameSite | Strict |
| Secure | ✅ |
| Max-Age | sessionTtlSeconds (default 86400) |
| Path | / |
cookie FE ตั้งเอง หลังได้
editSessionId— BE ไม่ Set-Cookie
Error Reference
| errorCode | เกิดตอน | FE ทำอะไร |
|---|---|---|
HTTP 404 Flow.NotFound | /access | error view |
verdict NOT_ALLOWED + Flow.MustNotBeLoggedIn | /access | แจ้ง logout |
verdict NOT_ALLOWED + Flow.InviteRequiredOrExpired | /access | error ขอ invite ใหม่ |
verdict NOT_ALLOWED + Flow.SelfStartNotAllowed | /access | error |
verdict REQUIRES_LOGIN | /access | redirect loginUrl |
Otp.DuplicateEmail | OTP Next | แสดง error + ปุ่ม login |
Otp.DuplicateAccount | OTP SaveDraft | แสดง error + ปุ่ม login |
Otp.Expired | OTP Next | ขอ OTP ใหม่ |
Otp.VerifyFailed | OTP Next | แสดง error |
HTTP 403 Flow.SelfStartNotAllowed | POST /instances | flow ต้องมี invite |