Onboarding DB Schema
15 tables ของ Onboarding flow ใน User Service (PostgreSQL) — ERD, property reference, state machine, indexes, FK order, notable behaviors, และ flow simulation (NEW_REQUESTOR)
อัปเดต: 2026-06-30
Database ของ User Service (PostgreSQL) — ครอบคลุม 15 table ที่เกี่ยวกับ Onboarding flow ตั้งแต่ config ไปถึง runtime, audit, session, invitation
Sections:
- ERD — ความสัมพันธ์หลัก (อ่านก่อนเพื่อ mental model)
- กลุ่มตาราง — จัดกลุ่มตามหน้าที่ (overview)
- รายละเอียดแต่ละ Table — ทุก column พร้อมคำอธิบาย (property reference)
- Status State Machine — สถานะ FlowInstance และ transition
- Indexes Summary — index ที่มีอยู่
- FK Cascade & Delete Order — ลำดับลบที่ปลอดภัย
- Notable Behaviors — behavior พิเศษที่ต้องรู้
- Flow Simulation (NEW_REQUESTOR) — ไล่ DB write ทีละ step (อ่านเพื่อเห็นภาพรวม)
ERD (Main Relationships)
erDiagram
StepCatalog {
uuid Id PK
string StepTypeCode UK
string DisplayName
string ExecutionMode "Interactive / Automatic"
bool LockedAfterFirstExecution
bool SensitiveInReview
bool ReworkAllowed "DB default true"
int MaxRetries
string DependenciesJson "jsonb"
}
FlowDefinition {
uuid Id PK
string Code UK "e.g. STANDARD"
string Name
uuid ServiceId "cross-context Codex, no FK"
string TemplateKey
string FrontendBaseUrl
int InvitationTtlDays
int SessionTtlSeconds
bool IsActive
bool AllowSelfStart
string RefNoIssueAt "OnEnterDraft / OnEnterSubmitted"
string DocumentConfigCode
int StaleDaysBeforeAbandon
string SpawnsCustomerFlowCode
string RequestorRoleCode
string AuthRequirement "Any / RequiresLogin / RequiresAnonymous"
string LoggedInRedirectFlowCode
string OnExistingAccountPolicy "Allow / RequireLogin"
}
FlowStep {
uuid Id PK
uuid FlowDefinitionId FK
uuid StepCatalogId FK
string StepType "denormalized"
int Order "0-based"
bool CanGoBack
}
FlowInstance {
uuid Id PK
uuid FlowDefinitionId "logical ref, no FK constraint"
string FlowSnapshotJson "jsonb snapshot at creation"
string Status "Draft/Submitted/Resubmitted/InReview/Reviewed/InApprove/Approved/Rejected/Rework/Finalized/Abandoned/Cancelled"
uuid OwnerUserId "nullable, cross-context User.Id"
uuid CompanyId "nullable, cross-context"
uuid ServiceId "nullable, cross-context"
string RefNo
bool RefNoPending
int CurrentStepIndex
bool Archived
string StuckAtStepType
string CancelledBy "Owner / Admin"
string CancelReason
string CompletedLockedStepsJson "jsonb"
string StepAttemptsJson "jsonb"
}
FlowInstanceStepData {
uuid FlowInstanceId PK "composite PK; FK via relationship"
string StepType PK "composite PK"
string DataJson "jsonb"
bool IsSkipped "true = copy from UserStepRecord"
datetime CompletedAt
}
FlowInstanceHistory {
uuid Id PK
uuid FlowInstanceId FK
int Sequence UK "monotonic per instance"
string FromStatus
string ToStatus
string Action "Submit / Resubmit / Approve / Reject / Rework / Finalize"
string ActorType "Owner / Admin / System"
uuid ActorId
string ActorName
string Reason
string ReworkContextJson "jsonb"
datetime OccurredAt
int SubmissionNo
bool IsSubmissionRow
string RefNo "scalar copy"
uuid CompanyId "scalar copy"
uuid OwnerUserId "scalar copy"
int CurrentStepIndex "scalar copy"
}
FlowInstanceHistoryStep {
uuid Id PK
uuid FlowInstanceHistoryId FK
string StepType UK "composite with HistoryId"
int StepOrder
string DataJson "jsonb frozen"
bool IsSensitive
bool IsSkipped
datetime CompletedAt
}
FlowInvestigationEvent {
uuid Id PK
uuid FlowInstanceId FK
string EventType "STEP_STUCK / STEP_RETRIED / STEP_FAILED"
string StepType
string ErrorCode
string Detail "no PII"
int AttemptNo
string CorrelationId
datetime CreatedAt
}
CorrectionRequest {
uuid Id PK
uuid FlowInstanceId FK
uuid ApprovalDecisionId UK
datetime RequestedAt
string RequestedFromStatus
uuid RequestedByEmployeeId
string TargetStepsJson "jsonb"
string DecidedByContextJson "jsonb"
}
EditSession {
uuid Id PK
string SessionId UK "__Host-onboarding cookie"
uuid FlowInstanceId FK
uuid UserId "nullable"
string Email
bool IsActive
datetime ExpiresAt
}
InvitationToken {
string Id PK "opaque random token"
string Email
string FlowCode
string Status "PENDING / CONSUMED / REVOKED / EXPIRED"
uuid CreatedByAdminId
datetime ExpiresAt
datetime ConsumedAt
uuid CompanyId
uuid OriginatingFlowInstanceId FK "nullable"
uuid ConsumedByFlowInstanceId "nullable FK to FlowInstance"
}
CompanyRequestor {
uuid Id PK
uuid CompanyId UK "1 company = 1 requestor"
uuid RequestorUserId "cross-context User.Id"
datetime CreatedAt
}
UserStepRecord {
uuid Id PK
string Email "linked by Email, no FK to FlowInstance"
string StepType
datetime CompletedAt
string DataJson "jsonb"
uuid CompanyId
uuid ServiceId
}
OnboardingSession {
uuid Id PK
string Email "linked by Email"
string EntraObjectId
string FlowSnapshotJson "legacy v1"
int CurrentStepIndex "legacy v1"
bool IsActive
datetime ExpiresAt
}
ServiceEnrollment {
uuid Id PK
uuid UserId "cross-context User.Id"
uuid ServiceId "cross-context, no FK (Service table dropped)"
datetime EnrolledAt
}
StepCatalog ||--o{ FlowStep : "defines steps"
FlowDefinition ||--o{ FlowStep : "contains"
FlowInstance ||--o{ FlowInstanceStepData : "stores"
FlowInstance ||--o{ FlowInstanceHistory : "records"
FlowInstance ||--o{ FlowInvestigationEvent : "logs"
FlowInstance ||--o{ CorrectionRequest : "receives"
FlowInstance ||--o{ EditSession : "cursored by"
FlowInstanceHistory ||--o{ FlowInstanceHistoryStep : "freezes (CASCADE)"
FlowInstance |o--o{ InvitationToken : "originates (nullable)"
กลุ่มตาราง
ส่วนนี้ = overview/mental model — จัดกลุ่มตามหน้าที่เพื่อเข้าใจภาพรวม ดูรายละเอียด column แต่ละ table ที่ section ถัดไป (
รายละเอียดแต่ละ Table)
Group 1 — Config / Seed (อย่าลบ)
| Table | หน้าที่ |
|---|---|
StepCatalog | Catalog ของ step type ทุกตัวที่ engine รู้จัก — Admin สร้าง, Seed data ใน migration |
FlowDefinition | Blueprint ของ flow — Admin สร้าง/แก้ผ่าน Admin UI |
FlowStep | Steps ใน FlowDefinition เรียงตาม Order (0-based) |
Key rules:
FlowStep.StepTypedenormalized จากStepCatalog.StepTypeCode— ใช้ join performanceFlowDefinition.ServiceIdเป็น cross-context Guid ไปหา Codex Service — ไม่มี FK constraint (dropped in 2026-05-28)StepCatalog.DependenciesJsonกำหนดRequired/Optionaldependency ระหว่าง step
Group 2 — Runtime Execution
FlowInstance (Main Aggregate)
1 row = 1 การ execute ของ FlowDefinition จาก user
Indexes:
OwnerUserIdStatusRefNo(CompanyId, ServiceId, Status)— composite สำหรับ admin listRefNoPending— background retry polling
Cross-context refs (plain Guid ไม่มี FK):
OwnerUserId→User.IdCompanyId→Company.IdServiceId→ Codex Service
Active Slot (IsActiveSlot()) — status ที่กิน uniqueness slot:
Draft | Submitted | Resubmitted | InReview | Reviewed | InApprove | Approved | Rework | Finalized
Terminal = Rejected | Abandoned | Cancelled (ไม่กิน slot → start ใหม่ได้)
FlowInstanceStepData
Composite PK (FlowInstanceId, StepType) — 1 row ต่อ step ที่ execute แล้ว
IsSkipped = true→ data copy มาจากUserStepRecord(D-V2-39 copy-on-skip)- Handler อ่านผลของ step อื่นด้วย StepType ไม่ใช่ index
FlowInvestigationEvent
Append-only diagnostics — ตอบ “อะไรพัง ตรงไหน” สำหรับ dev/ops
ห้าม PII — ไม่มี id-card number / personal info
Group 3 — Audit History
FlowInstanceHistory
Append-only timeline — 1 row ต่อ 1 status change
Sequencemonotonic ต่อ instance (unique constraint)- Scalar copy ของ RefNo, CompanyId, OwnerUserId ณ เวลานั้น → อ่าน card โดยไม่ต้อง join
FlowInstanceHistoryStep
Frozen snapshot ของ step data ณ ตอน submit — CASCADE ลบตาม History
- เฉพาะ submission row (Draft→Submitted, Rework→Submitted)
IsSensitive = true→ API ไม่เคย return DataJson ออก
Group 4 — Correction / Rework
CorrectionRequest
Append-only — 1 row ต่อครั้งที่ Admin ขอให้ Requestor แก้
ApprovalDecisionIdUK — idempotency (กัน duplicate จาก ASB retry)TargetStepsJson:[{stepType, note, autoIncluded}]— step ที่ต้องแก้ + per-step note
Group 5 — Sessions
| Table | หน้าที่ | สถานะ |
|---|---|---|
EditSession | Cookie cursor → FlowInstance (v2 anonymous session) | Active |
OnboardingSession | v1 OTP session | Legacy/Obsolete |
UserStepRecord | Cache ของ step ที่ user เคยทำ — ใช้ copy-on-skip | Active |
EditSession:
SessionId=__Host-onboardingcookie value (UK)- ตาย TTL → FlowInstance ยังอยู่ → เปิด EditSession ใหม่ resume ได้
- anonymous phase:
UserId = null,Email = ""; OTP complete → bind owner
UserStepRecord:
- key:
Email + StepType(+ optionalCompanyId,ServiceIdสำหรับ per-company scope) - FlowEngine อ่านจากนี้ตอน skip step ที่เคยทำในรอบก่อน
Group 6 — Invitations
InvitationToken
Token = opaque random string (ไม่ใช่ JWT) ส่งไปใน email link ?code=<token>
OriginatingFlowInstanceId— FlowInstance ของ Requestor ที่สร้าง tokenConsumedByFlowInstanceId— FlowInstance ของ Customer ที่ใช้ token- ทั้งสอง FK เป็น nullable → ไม่บล็อกลบ FlowInstance
Group 7 — Identity Bindings
CompanyRequestor
1 Company = 1 Requestor วันนี้ (UK บน CompanyId)
- ถ้าต้องการ M ในอนาคต: drop UK → เพิ่ม
UK(CompanyId, RequestorUserId) RequestorUserId→User.Id(cross-context, ไม่มี FK)
ServiceEnrollment
User enrolled ใน Service ใด
ServiceId= cross-context Codex ServiceId — FK constraint ถูก drop (migration 2026-05-28 removed Services table)- Table ยังอยู่แต่ไม่มี FK
รายละเอียดแต่ละ Table
ส่วนนี้ = property reference — ทุก column พร้อมคำอธิบายและ type สำหรับค้นหาขณะเขียนโค้ด ภาพรวมดูที่ section
กลุ่มตารางด้านบน
Config / Seed Tables
StepCatalog — “เมนูกลาง” ของ step type ทั้งหมดที่ระบบรู้จัก — Admin เพิ่มที่นี่ก่อน ถึงจะใช้ใน FlowDefinition ได้
ข้อมูลส่วนใหญ่ใส่ตั้งแต่ตอน migration (Seed data) ไม่ค่อยเปลี่ยน ปัจจุบันมี 3 step type: ConsentStep, NdidStep, PersonalInfoStep
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
StepTypeCode | string | UK — ชื่อ step เช่น ConsentStep, NdidStep |
DisplayName | string | ชื่อที่แสดงบน UI เช่น “ยืนยันตัวตนผ่าน NDID” |
Description | string? | คำอธิบายเพิ่มเติมสำหรับ Admin |
SkipScope | enum | กำหนดว่า step นี้ skip ได้ระดับไหน (ต่อ user / ต่อบริษัท) |
ExecutionMode | enum | Interactive (user กรอก) / Automatic (ระบบทำเอง) |
LockedAfterFirstExecution | bool | true = ทำครั้งแรกแล้วแก้ไม่ได้ เช่น NdidStep |
SensitiveInReview | bool | true = API ซ่อน DataJson ตอน review เช่น NDID identity |
ReworkAllowed | bool | true = ถ้า Rework ผู้สมัครแก้ step นี้ได้ (DB default: true) |
DependenciesJson | string? | jsonb — dependency ระหว่าง step {"Required":[...], "Optional":[...]} |
MaxRetries | int | retry สูงสุดสำหรับ automatic step |
RetryBackoffSeconds | int | รอกี่วินาทีระหว่าง retry |
CreatedAt | DateTime | audit |
CreatedBy | string | audit |
FlowDefinition — template ของ onboarding flow บอกว่ามี step อะไร เรียงยังไง และมีกฎอะไรบ้าง
เช่น flow STANDARD มี 3 step: Consent → NDID → PersonalInfo, AllowSelfStart = true (user เริ่มเองได้)
Admin แก้ได้ผ่าน Admin UI แต่ instance ที่สร้างไปแล้วไม่กระทบ เพราะระบบ snapshot definition ไว้ใน FlowInstance ตั้งแต่ตอนสร้าง
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
Code | string | UK — รหัส flow เช่น STANDARD ใช้อ้างอิงใน code |
Name | string | ชื่อแสดงบน UI เช่น “Standard Onboarding” |
ServiceId | Guid? | cross-context Codex Service — ไม่มี FK constraint |
TemplateKey | string? | ชี้ไปที่ document template ที่ใช้ออกใบสมัคร |
FrontendBaseUrl | string? | base URL ของ frontend สำหรับ flow นี้ |
InvitationTtlDays | int? | อายุ invitation token (วัน) |
SessionTtlSeconds | int | อายุ EditSession (วินาที) — STANDARD = 86400 (24 ชม.) |
IsActive | bool | false = ปิด flow ไม่รับสมัครใหม่ |
AllowSelfStart | bool | true = user เริ่มเองได้ โดยไม่ต้องรอ invitation |
RefNoIssueAt | string? | OnEnterDraft หรือ OnEnterSubmitted — เวลาที่ออก RefNo |
DocumentConfigCode | string? | config code สำหรับ PDF generation |
StaleDaysBeforeAbandon | int? | จำนวนวันที่ไม่มี activity ก่อน abandon อัตโนมัติ |
SpawnsCustomerFlowCode | string? | flow code ที่สร้างให้ Customer อัตโนมัติหลัง Requestor Finalize |
StaleSubmittedWarnDays | int? | แจ้งเตือนก่อน abandon กี่วัน |
RequestorRoleCode | string? | role ที่ต้องมีถึงจะ start flow นี้ได้ |
AuthRequirement | enum? | Any / RequiresLogin / RequiresAnonymous |
LoggedInRedirectFlowCode | string? | redirect ไปที่ flow นี้ถ้า user login อยู่แล้ว |
OnExistingAccountPolicy | enum? | Allow / RequireLogin — กรณี user มี account อยู่แล้ว |
CreatedAt / UpdatedAt | DateTime / DateTime? | audit |
FlowStep — รายการ step แต่ละตัวใน FlowDefinition เรียงตาม Order
เมื่อสร้าง FlowInstance ระบบดึง FlowStep เหล่านี้มา snapshot ไว้ใน FlowSnapshotJson เพื่อล็อกลำดับ ณ เวลานั้น
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
FlowDefinitionId | Guid | FK → FlowDefinition |
StepCatalogId | Guid | FK → StepCatalog |
StepType | string | denormalized จาก StepCatalog.StepTypeCode — ใช้แทน join |
Order | int | ลำดับ step ใน flow (0-based) — STANDARD: Consent=0, NDID=1, PersonalInfo=2 |
CanGoBack | bool | true = user กด Back ย้อนกลับไป step ก่อนหน้าได้ |
CreatedAt | DateTime | audit |
Runtime Execution Tables
FlowInstance — table หลัก 1 row = user 1 คน เริ่ม onboarding 1 ครั้ง
Status วิ่งตาม State Machine: Draft → Submitted → InReview → Reviewed → InApprove → Approved/Rejected/Rework → Finalized เก็บทุกอย่างที่ต้องรู้เกี่ยวกับ session การ onboard: อยู่ที่ step ไหน, status ปัจจุบัน, เลขใบสมัคร
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
RefNo | string? | เลขอ้างอิงใบสมัคร เช่น ONB-2026-00001 — null จนกว่า background job จะ assign |
FlowDefinitionId | Guid | logical ref ไปหา FlowDefinition — ไม่มี FK constraint (ดู snapshot แทน) |
FlowSnapshotJson | string | jsonb snapshot ของ FlowDefinition+Steps ณ ตอนสร้าง — ล็อก step order ตลอด flow |
Status | enum | Draft / Submitted / Resubmitted / InReview / Reviewed / InApprove / Approved / Rejected / Rework / Finalized / Abandoned / Cancelled |
OwnerUserId | Guid? | cross-context User.Id — null ระหว่าง anonymous phase (ก่อนยืนยัน OTP) |
CompanyId | Guid? | cross-context Company.Id — set หลัง PersonalInfo step เสร็จ |
ServiceId | Guid? | cross-context Codex Service — ไม่มี FK |
CurrentStepIndex | int | index ของ step ที่กำลังทำอยู่ (0-based ตาม snapshot) |
StuckAtStepType | string? | step ที่ automatic engine ติดค้างอยู่ — รอ retry |
CompletedLockedStepsJson | string | jsonb list ของ step types ที่ lock แล้ว เช่น ["NdidStep"] |
StepAttemptsJson | string | jsonb retry count ต่อ step เช่น {"NdidStep": 2} |
RefNoPending | bool | true = รอ background job assign RefNo อยู่ |
Archived | bool | true = ซ่อนจาก default list |
ArchivedAt | DateTime? | เวลา archive |
SubmittedAt | DateTime? | เวลา submit ครั้งแรก |
FinalizedAt | DateTime? | เวลา finalize |
CancelledBy | enum? | Owner / Admin — ใคร cancel |
CancelReason | string? | เหตุผล cancel |
CreatedAt / CreatedBy | DateTime / string | audit |
UpdatedAt / UpdatedBy | DateTime? / string? | audit |
FlowInstanceStepData — ข้อมูลที่ user กรอกในแต่ละ step บันทึกเป็น JSON ต่อ step
Composite PK (FlowInstanceId, StepType) — 1 step = 1 row ต่อ instance
ถ้า step ถูก skip ระบบ copy ข้อมูลจาก UserStepRecord มาใส่ (IsSkipped = true) user ไม่ต้องกรอกใหม่
| Column | Type | คำอธิบาย |
|---|---|---|
FlowInstanceId | Guid | PK + FK → FlowInstance |
StepType | string | PK — ชนิดของ step เช่น PersonalInfoStep |
DataJson | string? | jsonb ข้อมูลที่ user กรอก / ระบบได้รับจาก step engine |
IsSkipped | bool | true = copy มาจาก UserStepRecord user ไม่ต้องกรอกใหม่ |
CompletedAt | DateTime? | เวลาที่ step เสร็จสมบูรณ์ |
CreatedAt / CreatedBy | DateTime / string | audit |
UpdatedAt / UpdatedBy | DateTime? / string? | audit |
Audit History Tables
FlowInstanceHistory — timeline การเปลี่ยน status ทุกครั้ง append-only ไม่เคยลบหรือแก้
1 row = 1 status change เช่น Draft→Submitted, InApprove→Approved เก็บ scalar copy ของ RefNo, CompanyId, OwnerUserId, CurrentStepIndex ไว้ในแต่ละ row — ทำให้ render audit card ได้โดยไม่ต้อง join FlowInstance
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
FlowInstanceId | Guid | FK → FlowInstance |
Sequence | int | เลขลำดับ monotonic ต่อ instance — UNIQUE (FlowInstanceId, Sequence) |
FromStatus | enum | status ก่อนเปลี่ยน |
ToStatus | enum | status หลังเปลี่ยน |
Action | enum | Submit / Resubmit / Approve / Reject / Rework / Finalize / Cancel / Abandon |
ActorType | enum | Owner / Admin / System |
ActorId | Guid? | user id ของคนที่กระทำ |
ActorName | string? | ชื่อของคนที่กระทำ (scalar copy ณ เวลานั้น) |
ActorEmployeeIdRaw | string? | employee id (raw string, cross-context) |
DecidedByContextJson | string? | jsonb — context ของคณะที่ approve/reject เช่น ชื่อ approver |
Reason | string? | เหตุผล reject / cancel (ถ้ามี) |
ReworkContextJson | string? | jsonb — step ที่ขอ rework + note per step |
OccurredAt | DateTime | เวลาที่เกิด event |
SubmissionNo | int? | ครั้งที่ submit (1, 2, 3…) — null ถ้าไม่ใช่ submission row |
IsSubmissionRow | bool | true = row นี้คือ submit event → จะมี HistoryStep rows freeze ไว้ |
RefNo | string? | scalar copy ณ เวลานั้น |
CompanyId | Guid? | scalar copy ณ เวลานั้น |
OwnerUserId | Guid? | scalar copy ณ เวลานั้น |
CurrentStepIndex | int | scalar copy ณ เวลานั้น |
FlowInstanceHistoryStep — snapshot ของ step data ณ ตอนที่ user กด Submit “freeze” ค่าไว้ตลอดกาล
มีเฉพาะใน submission rows (IsSubmissionRow = true) เช่น Draft→Submitted หรือ Rework→Submitted ใช้แสดงว่า submit รอบที่ N user กรอกอะไรไว้ — ถ้า IsSensitive = true (เช่น NdidStep) API ซ่อน DataJson
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
FlowInstanceHistoryId | Guid | FK → FlowInstanceHistory (CASCADE delete) |
StepType | string | ชนิด step — UNIQUE (FlowInstanceHistoryId, StepType) |
StepOrder | int | ลำดับ step ใน snapshot นั้น |
DataJson | string? | jsonb ข้อมูล step ณ ตอน submit (frozen ไม่เคยเปลี่ยน) |
IsSensitive | bool | true = API ซ่อน DataJson ตอน review (เช่น NDID identity) |
IsSkipped | bool | true = step นี้ถูก skip มา copy จาก UserStepRecord |
CompletedAt | DateTime? | เวลาที่ step เสร็จ |
Diagnostics
FlowInvestigationEvent — log ทุกครั้งที่ step ติด, retry, หรือ failed — append-only, ไม่มี PII
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
FlowInstanceId | Guid | FK → FlowInstance |
EventType | string | STEP_STUCK / STEP_RETRIED / STEP_FAILED |
StepType | string? | step ที่มีปัญหา |
ErrorCode | string? | error code จาก step engine |
Detail | string? | รายละเอียดทางเทคนิค (ห้าม PII เด็ดขาด) |
AttemptNo | int? | ครั้งที่เกิด (1, 2, 3…) |
CorrelationId | string? | request id สำหรับ trace log ข้าม service |
CreatedAt | DateTime | เวลา event |
Correction / Rework
CorrectionRequest — บันทึกทุกครั้งที่ Approver ขอให้ Requestor กลับไปแก้ข้อมูลในบาง step
1 row = 1 ครั้งที่ขอแก้ append-only ไม่ลบ
ApprovalDecisionId (Unique) ป้องกัน duplicate กรณี message ถูกส่งซ้ำจาก message broker (idempotency)
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
FlowInstanceId | Guid | FK → FlowInstance |
ApprovalDecisionId | Guid | UK — idempotency key กัน duplicate จาก ASB retry |
RequestedAt | DateTime | เวลาที่ขอแก้ |
RequestedFromStatus | enum | status ก่อน Rework เช่น InApprove |
RequestedByEmployeeId | Guid? | employee id ของ Approver ที่ขอแก้ |
TargetStepsJson | string | jsonb: [{stepType, note, autoIncluded}] — step ที่ต้องแก้ + per-step note |
DecidedByContextJson | string? | jsonb — context ของคณะผู้ตัดสิน |
CreatedAt / CreatedBy | DateTime / string | audit |
Sessions
EditSession — ผูก browser session (cookie) เข้ากับ FlowInstance ที่กำลังทำอยู่
SessionId = ค่าใน __Host-onboarding cookie ที่ browser ถือ
anonymous phase: UserId = null, Email = ""; หลัง OTP สำเร็จ = bind user เข้า session
แม้ cookie หมดอายุ FlowInstance ยังอยู่ — user กลับมา browser ใหม่ สร้าง EditSession ใหม่ resume ต่อจากเดิมได้
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
SessionId | string | UK — cookie value ที่ browser ถือ |
FlowInstanceId | Guid | FK → FlowInstance |
UserId | Guid? | null = anonymous phase; bind หลัง OTP สำเร็จ |
Email | string | email ของ user (empty ระหว่าง anonymous phase) |
IsActive | bool | false = session ถูกปิด (logout หรือ new session) |
ExpiresAt | DateTime | TTL — หมดแล้วสร้าง EditSession ใหม่ได้ |
CreatedAt / CreatedBy | DateTime / string | audit |
UpdatedAt / UpdatedBy | DateTime? / string? | audit |
OnboardingSession — session v1 เดิม (ก่อน refactor มาเป็น EditSession) ไม่ใช้แล้ว
table ยังอยู่ใน DB แต่ code ใหม่ไม่ write ลงมาที่นี่แล้ว ไม่ต้องสนใจในการ implement ใหม่
UserStepRecord — cache ว่า email นี้เคยทำ step อะไรเสร็จแล้วบ้าง (ข้ามรอบ)
พอ user ต้อง onboard อีกครั้ง (เช่น ถูก reject แล้ว resubmit หรือเปิด service ใหม่) ระบบดึง record นี้มา auto-fill ใน FlowInstanceStepData แทนที่จะต้องกรอกใหม่
key: Email + StepType บวก optional CompanyId / ServiceId สำหรับ scope ต่อบริษัท
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
Email | string | email ของ user (link โดย email ไม่มี FK ไป FlowInstance) |
StepType | string | ชนิด step เช่น ConsentStep, PersonalInfoStep |
CompletedAt | DateTime | เวลาที่ทำ step เสร็จ |
DataJson | string? | jsonb ข้อมูลที่ cache ไว้ — copy มาจาก FlowInstanceStepData |
CompanyId | Guid? | scope per company (optional) |
ServiceId | Guid? | scope per service (optional) |
CreatedAt / CreatedBy | DateTime / string | audit |
Invitations
InvitationToken — token ที่ Admin สร้างและส่งให้ Customer ทาง email เป็น random string ธรรมดา (ไม่ใช่ JWT)
เมื่อ Customer กดลิงก์ในอีเมล (?code=<token>) ระบบ validate token นี้แล้วเริ่ม onboarding ให้อัตโนมัติ
มี 2 nullable FK กลับ FlowInstance: ของ Requestor ที่สร้าง (OriginatingFlowInstanceId) และของ Customer ที่ใช้ (ConsumedByFlowInstanceId)
| Column | Type | คำอธิบาย |
|---|---|---|
Id | string | PK — opaque random token ส่งใน email link |
Email | string | email ของ Customer ที่รับ invitation |
FlowCode | string | flow ที่ Customer จะถูก start เมื่อใช้ token |
Status | string | PENDING → CONSUMED / REVOKED / EXPIRED |
CreatedByAdminId | Guid | admin user ที่สร้าง token |
ExpiresAt | DateTime | token หมดอายุ |
ConsumedAt | DateTime? | เวลาที่ Customer ใช้ token |
CompanyId | Guid? | บริษัทที่ token นี้ผูกอยู่ |
OriginatingFlowInstanceId | Guid? | FK (nullable) → FlowInstance ของ Requestor |
ConsumedByFlowInstanceId | Guid? | FK (nullable) → FlowInstance ของ Customer |
CreatedAt / CreatedBy | DateTime / string | audit |
Identity Bindings
CompanyRequestor — บันทึกว่าบริษัทนี้มี Requestor (คนยื่นเรื่องแทนบริษัท) คือใคร
UK บน CompanyId enforce 1 บริษัท = 1 Requestor
เขียนตอน Finalize ของ Requestor flow สำเร็จ
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
CompanyId | Guid | UK — cross-context Company.Id |
RequestorUserId | Guid | cross-context User.Id — ไม่มี FK |
CreatedAt | DateTime | เวลา bind |
ServiceEnrollment — บันทึกว่า user enroll ใน service ใดแล้ว
ServiceId = cross-context Codex ServiceId — ไม่มี FK (Services table ถูก drop ใน migration 2026-05-28)
| Column | Type | คำอธิบาย |
|---|---|---|
Id | Guid | PK |
UserId | Guid | cross-context User.Id |
ServiceId | Guid | cross-context Codex ServiceId |
EnrolledAt | DateTime | เวลา enroll |
CreatedAt / CreatedBy | DateTime / string | audit |
Status State Machine
┌──────────────────────────────┐
│ DRAFT │ ← สร้างใหม่
└──────────────┬───────────────┘
│ Submit
▼
┌──────────────────────────────┐
│ SUBMITTED │
└──────────────┬───────────────┘
│ (Orchestrator picks up)
┌───────────┴──────────────┐
▼ ▼
┌─────────────┐ ┌────────────────┐
│ IN_REVIEW │──Reviewed▶│ REVIEWED │
└─────────────┘ └───────┬────────┘
│
┌───────▼────────┐
│ IN_APPROVE │
└───────┬────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌──────────────┐
│ APPROVED │ │ REJECTED │ │ REWORK │
└─────┬──────┘ └──────────── ┘ └──────┬───────┘
│ (Finalize saga) │ Resubmit
▼ ▼
┌─────────────┐ ┌───────────────────┐
│ FINALIZED │ │ RESUBMITTED │──▶ (back to IN_REVIEW)
└─────────────┘ └───────────────────┘
Terminal states (ไม่กิน active slot):
REJECTED | ABANDONED | CANCELLED
Abandon: ทุก non-terminal state → ABANDONED (OwnerUserId / Admin)
Cancel: non-terminal state → CANCELLED (Owner self / Admin explicit)
Indexes Summary
| Table | Index | Type |
|---|---|---|
FlowInstance | OwnerUserId | BTree |
FlowInstance | Status | BTree |
FlowInstance | RefNo | BTree |
FlowInstance | (CompanyId, ServiceId, Status) | BTree composite |
FlowInstance | RefNoPending | BTree |
FlowInstanceHistory | FlowInstanceId | BTree |
FlowInstanceHistory | (FlowInstanceId, Sequence) | UNIQUE |
FlowInstanceHistoryStep | FlowInstanceHistoryId | BTree |
FlowInstanceHistoryStep | (FlowInstanceHistoryId, StepType) | UNIQUE |
CorrectionRequest | ApprovalDecisionId | UNIQUE |
CorrectionRequest | FlowInstanceId | BTree |
FlowInvestigationEvent | FlowInstanceId | BTree |
EditSession | SessionId | UNIQUE |
EditSession | FlowInstanceId | BTree |
CompanyRequestor | CompanyId | UNIQUE |
CompanyRequestor | RequestorUserId | BTree |
FK Cascade & Delete Order
FlowInstanceHistoryStep เป็นตารางเดียวที่มี ON DELETE CASCADE ชัดเจน (ผ่าน EF HasMany…OnDelete(Cascade))
สำหรับ cleanup script ให้ลบตามลำดับนี้:
1. FlowInstanceHistoryStep (grandchild, FK → FlowInstanceHistory)
2. FlowInstanceHistory (FK → FlowInstance)
3. FlowInstanceStepData (FK → FlowInstance)
4. CorrectionRequest (FK → FlowInstance)
5. FlowInvestigationEvent (FK → FlowInstance)
6. EditSession (FK → FlowInstance)
7. InvitationToken (nullable FKs → nullify หรือ delete)
8. FlowInstance (parent)
── per-user cleanup ──
9. CompanyRequestor (by RequestorUserId)
10. UserStepRecord (by Email)
11. OnboardingSession (by Email, legacy)
ไม่แตะ: FlowDefinition, FlowStep, StepCatalog (config/seed)
Notable Behaviors
| Behavior | Where |
|---|---|
FlowSnapshotJson — flow definition ถูก clone ตอนสร้าง FlowInstance; แก้ FlowDefinition ไม่กระทบ instance เก่า | FlowInstance |
IsActiveSlot() — กำหนดว่า instance นั้นยัง “ครอง” slot uniqueness อยู่ไหม; engine ใช้ check ก่อน start ใหม่ | FlowInstance |
StepAttemptsJson — retry budget ของ automatic step; {"StepType": attemptCount} | FlowInstance |
FlowInstanceHistory ต้องไม่เป็น AggregateRoot — กัน dispatch recursion ใน DbContext | FlowInstanceHistory |
CorrectionRequest.TargetStepsJson รับทั้ง format ใหม่ [{stepType,note}] และ legacy ["A","B"] | CorrectionRequest |
ServiceId บน FlowDefinition และ FlowInstance = cross-context Codex Guid — ไม่มี FK constraint | FK-less by design |
OwnerUserId, CompanyId บน FlowInstance = cross-context — ไม่มี FK constraint | FK-less by design |
FlowInstanceHistory เขียนโดย domain event listener (FlowInstanceStatusChangedNotificationHandler) ที่ fire ตอน SaveChanges — ไม่ใช่ handler โดยตรง (ยกเว้น ApplyFlowProgress ที่เขียนตรง) | FlowInstanceHistory |
UserStepRecord เขียนตอน Finalize เท่านั้น (WriteLedgerAsync) — ไม่ได้เขียนทีละ step | UserStepRecord |
ServiceEnrollment ไม่ได้เขียนใน onboarding flow handler — เขียนผ่าน async provisioning จาก Orchestrator หลัง Finalize | ServiceEnrollment |
Flow Simulation — NEW_REQUESTOR Onboarding (Happy Path)
ติดตามข้อมูลใน DB จากต้นจนจบ — ผู้สมัครใหม่ somchai@example.com (ไม่มี account เดิม, ไม่มี JWT) ทำ NEW_REQUESTOR flow (OTP → JuristicBinding → … → FxRequestorTcConsent) จน Finalized
NEW_REQUESTOR step sequence เป็น ops-seeded (ไม่อยู่ใน EF HasData) — Order ขึ้นอยู่กับ DB ที่ seed ไว้
Placeholders:
<A>= FlowInstance Id,<UserId>= UserId ที่สร้างจาก OTP step,<CompanyId>= CompanyId หลัง Approve
1 — Anonymous entry (StartRequestorFlow)
ผู้ใช้ไม่มี JWT — ส่งแค่ email มาใน body. Handler สร้าง FlowInstance แบบ owner-less + EditSession cookie สำหรับ anonymous session
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstance | INSERT | Id=<A>, Status=Draft, OwnerUserId=null, CurrentStepIndex=0, FlowSnapshotJson=snapshot NEW_REQUESTOR+N steps |
EditSession | INSERT | SessionId=<cookie>, FlowInstanceId=<A>, Email="", ExpiresAt=now+SessionTtlSeconds |
FlowInstanceHistory ยังไม่มี — Draft ไม่ fire status change event
2 — OtpVerificationStep: user กรอก email (ExecuteStepAction / SaveDraft)
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstanceStepData | UPSERT | (FlowInstanceId=<A>, StepType=OtpVerificationStep), DataJson={email:"somchai@example.com"} |
EditSession | UPDATE | Email="somchai@example.com" (lock per instance — ป้องกัน session นี้ถูก reassign email) |
OTP code ส่งออกผ่าน NS email (ไม่เขียน DB) — NS stamp pending hash ลง Redis
3 — OtpVerificationStep: user ยืนยัน OTP (ExecuteStepAction / Next)
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstanceStepData | UPDATE | DataJson={email, oid:"<EntraOid>"}, CompletedAt=now |
FlowInstance | UPDATE | OwnerUserId=<UserId>, CurrentStepIndex=1 |
Entra identity ถูก deactivate (
accountEnabled=false) — จะ activate อีกครั้งที่ Finalize (ADR-0034)
4 — Business steps: JuristicBinding → SignatoryForm (ExecuteStepAction / Next ×6)
Steps ที่เหลือก่อน Submit ทำ pattern เดียวกัน — แสดงรวมเป็น 1 กลุ่ม
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstanceStepData | UPSERT per step | StepType = JuristicBindingStep / RequesterInfoStep / CompanyInfoStep / UserModeStep / DesignateCustomersStep / SignatoryFormStep ตามลำดับ |
FlowInstance | UPDATE per step | CurrentStepIndex เพิ่มขึ้นทีละ 1 |
5 — FxRequestorTcConsentStep: กด Next = Submit (ExecuteStepAction / Submit)
Step สุดท้ายของ interactive flow — action Next บน step นี้ถูก engine map เป็น Submit path (FxRequestorTcConsentStep รวม T&C consent + dependency check ก่อน submit)
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstanceStepData | UPSERT | (FlowInstanceId=<A>, StepType=FxRequestorTcConsentStep), DataJson={agreed:true, tcVersion:...}, CompletedAt=now |
FlowInstance | UPDATE | Status=Submitted, SubmittedAt=now, RefNo="ONB-2026-XXXXX", RefNoIssuedAt=now |
FlowInstanceHistory | INSERT | Sequence=1, FromStatus=Draft, ToStatus=Submitted, Action=Submit, IsSubmissionRow=true, SubmissionNo=1 |
FlowInstanceHistoryStep | INSERT ×N | freeze ทุก step ณ เวลา submit — OtpVerificationStep (IsSensitive=false), business steps ต่างๆ |
FlowInstanceHistory+FlowInstanceHistoryStepเขียนโดย domain event listener ณ SaveChanges (atomic กับ status flip)
6 — ApplyFlowProgress: InReview → Reviewed → InApprove (3 hops)
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstance | UPDATE ×3 | Status: Submitted→InReview, InReview→Reviewed, Reviewed→InApprove |
FlowInstanceHistory | INSERT ×3 | Seq 2–4, Action=InReview/Reviewed/InApprove, IsSubmissionRow=false (ไม่มี HistoryStep) |
7 — Approve → Finalize อัตโนมัติ (TransitionFlowInstance[Approve])
| Table | Op | ค่าสำคัญ |
|---|---|---|
Company | UPSERT | สร้าง/อัปเดต Company จากข้อมูล CompanyInfoStep |
FlowInstance | UPDATE | Status=Approved, CompanyId=<CompanyId> |
CompanyRequestor | INSERT | (CompanyId=<CompanyId>, RequestorUserId=<UserId>) — idempotent |
CompanyApplication | INSERT | (CompanyId, ServiceId) — ถ้ายังไม่มี |
FlowInstance | UPDATE | Status=Finalized, FinalizedAt=now (auto-chain หลัง Approve) |
UserStepRecord | INSERT ×N | cache step ที่ eligible สำหรับ skip ในรอบถัดไป — เขียนตอน Finalize (WriteLedgerAsync) |
FlowInstanceHistory | INSERT | Sequence=5, Action=Approve, IsSubmissionRow=false |
FlowInstanceHistory | INSERT | Sequence=6, Action=Finalize, IsSubmissionRow=false |
Approve + Finalize fire domain event ใน SaveChanges เดียวกัน → 2 History rows พร้อมกัน
สรุปสุดท้าย — สิ่งที่อยู่ใน DB หลัง Happy Path เสร็จ
| Table | Rows | หมายเหตุ |
|---|---|---|
FlowInstance | 1 | Status=Finalized, OwnerUserId=<UserId>, CompanyId=<CompanyId> |
EditSession | 1 | ยังคงอยู่ (expired ตาม TTL เอง, ไม่ลบ) |
FlowInstanceStepData | N | OtpVerification + business steps ทั้งหมด |
FlowInstanceHistory | 6 | Seq 1–6: Submit, InReview, Reviewed, InApprove, Approve, Finalize |
FlowInstanceHistoryStep | N | freeze ของ Submit (Seq=1) เท่านั้น |
CompanyRequestor | 1 | ผูก Company ↔ Requestor |
CompanyApplication | 1 | ผูก Company ↔ Service |
UserStepRecord | N | cache step ที่ eligible สำหรับ skip ในรอบถัดไป |
ServiceEnrollment ไม่มีใน rows ด้านบน — เขียนแยกผ่าน Orchestrator async provisioning
Rework Path — ต่างจาก Happy Path อย่างไร
เมื่อ Approver ขอแก้แทนที่จะ Approve (TransitionFlowInstance[Rework]):
| Table | Op | ค่าสำคัญ |
|---|---|---|
CorrectionRequest | INSERT | TargetStepsJson=[{stepType, note}] step ที่ต้องแก้ |
FlowInstance | UPDATE | Status=Rework |
FlowInstanceHistory | INSERT | Sequence=5, Action=Rework, IsSubmissionRow=false |
เมื่อ User กด Submit อีกครั้ง (Resubmit) หลังแก้ข้อมูล:
| Table | Op | ค่าสำคัญ |
|---|---|---|
FlowInstanceStepData | UPSERT | อัปเดต step ที่แก้ใหม่ |
FlowInstance | UPDATE | Status=Resubmitted |
FlowInstanceHistory | INSERT | Sequence=6, IsSubmissionRow=true, SubmissionNo=2 |
FlowInstanceHistoryStep | INSERT ×N | freeze ชุดที่ 2 ณ resubmit ครั้งนี้ (ประวัติแยกจากชุดแรก) |