Private Docs

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:

  1. ERD — ความสัมพันธ์หลัก (อ่านก่อนเพื่อ mental model)
  2. กลุ่มตาราง — จัดกลุ่มตามหน้าที่ (overview)
  3. รายละเอียดแต่ละ Table — ทุก column พร้อมคำอธิบาย (property reference)
  4. Status State Machine — สถานะ FlowInstance และ transition
  5. Indexes Summary — index ที่มีอยู่
  6. FK Cascade & Delete Order — ลำดับลบที่ปลอดภัย
  7. Notable Behaviors — behavior พิเศษที่ต้องรู้
  8. 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หน้าที่
StepCatalogCatalog ของ step type ทุกตัวที่ engine รู้จัก — Admin สร้าง, Seed data ใน migration
FlowDefinitionBlueprint ของ flow — Admin สร้าง/แก้ผ่าน Admin UI
FlowStepSteps ใน FlowDefinition เรียงตาม Order (0-based)

Key rules:

  • FlowStep.StepType denormalized จาก StepCatalog.StepTypeCode — ใช้ join performance
  • FlowDefinition.ServiceId เป็น cross-context Guid ไปหา Codex Service — ไม่มี FK constraint (dropped in 2026-05-28)
  • StepCatalog.DependenciesJson กำหนด Required/Optional dependency ระหว่าง step

Group 2 — Runtime Execution

FlowInstance (Main Aggregate)

1 row = 1 การ execute ของ FlowDefinition จาก user

Indexes:

  • OwnerUserId
  • Status
  • RefNo
  • (CompanyId, ServiceId, Status) — composite สำหรับ admin list
  • RefNoPending — background retry polling

Cross-context refs (plain Guid ไม่มี FK):

  • OwnerUserIdUser.Id
  • CompanyIdCompany.Id
  • ServiceId → 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

  • Sequence monotonic ต่อ 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 แก้

  • ApprovalDecisionId UK — idempotency (กัน duplicate จาก ASB retry)
  • TargetStepsJson: [{stepType, note, autoIncluded}] — step ที่ต้องแก้ + per-step note

Group 5 — Sessions

Tableหน้าที่สถานะ
EditSessionCookie cursor → FlowInstance (v2 anonymous session)Active
OnboardingSessionv1 OTP sessionLegacy/Obsolete
UserStepRecordCache ของ step ที่ user เคยทำ — ใช้ copy-on-skipActive

EditSession:

  • SessionId = __Host-onboarding cookie value (UK)
  • ตาย TTL → FlowInstance ยังอยู่ → เปิด EditSession ใหม่ resume ได้
  • anonymous phase: UserId = null, Email = ""; OTP complete → bind owner

UserStepRecord:

  • key: Email + StepType (+ optional CompanyId, ServiceId สำหรับ per-company scope)
  • FlowEngine อ่านจากนี้ตอน skip step ที่เคยทำในรอบก่อน

Group 6 — Invitations

InvitationToken

Token = opaque random string (ไม่ใช่ JWT) ส่งไปใน email link ?code=<token>

  • OriginatingFlowInstanceId — FlowInstance ของ Requestor ที่สร้าง token
  • ConsumedByFlowInstanceId — FlowInstance ของ Customer ที่ใช้ token
  • ทั้งสอง FK เป็น nullable → ไม่บล็อกลบ FlowInstance

Group 7 — Identity Bindings

CompanyRequestor

1 Company = 1 Requestor วันนี้ (UK บน CompanyId)

  • ถ้าต้องการ M
    ในอนาคต: drop UK → เพิ่ม UK(CompanyId, RequestorUserId)
  • RequestorUserIdUser.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

ColumnTypeคำอธิบาย
IdGuidPK
StepTypeCodestringUK — ชื่อ step เช่น ConsentStep, NdidStep
DisplayNamestringชื่อที่แสดงบน UI เช่น “ยืนยันตัวตนผ่าน NDID”
Descriptionstring?คำอธิบายเพิ่มเติมสำหรับ Admin
SkipScopeenumกำหนดว่า step นี้ skip ได้ระดับไหน (ต่อ user / ต่อบริษัท)
ExecutionModeenumInteractive (user กรอก) / Automatic (ระบบทำเอง)
LockedAfterFirstExecutionbooltrue = ทำครั้งแรกแล้วแก้ไม่ได้ เช่น NdidStep
SensitiveInReviewbooltrue = API ซ่อน DataJson ตอน review เช่น NDID identity
ReworkAllowedbooltrue = ถ้า Rework ผู้สมัครแก้ step นี้ได้ (DB default: true)
DependenciesJsonstring?jsonb — dependency ระหว่าง step {"Required":[...], "Optional":[...]}
MaxRetriesintretry สูงสุดสำหรับ automatic step
RetryBackoffSecondsintรอกี่วินาทีระหว่าง retry
CreatedAtDateTimeaudit
CreatedBystringaudit

FlowDefinition — template ของ onboarding flow บอกว่ามี step อะไร เรียงยังไง และมีกฎอะไรบ้าง

เช่น flow STANDARD มี 3 step: Consent → NDID → PersonalInfo, AllowSelfStart = true (user เริ่มเองได้) Admin แก้ได้ผ่าน Admin UI แต่ instance ที่สร้างไปแล้วไม่กระทบ เพราะระบบ snapshot definition ไว้ใน FlowInstance ตั้งแต่ตอนสร้าง

ColumnTypeคำอธิบาย
IdGuidPK
CodestringUK — รหัส flow เช่น STANDARD ใช้อ้างอิงใน code
Namestringชื่อแสดงบน UI เช่น “Standard Onboarding”
ServiceIdGuid?cross-context Codex Service — ไม่มี FK constraint
TemplateKeystring?ชี้ไปที่ document template ที่ใช้ออกใบสมัคร
FrontendBaseUrlstring?base URL ของ frontend สำหรับ flow นี้
InvitationTtlDaysint?อายุ invitation token (วัน)
SessionTtlSecondsintอายุ EditSession (วินาที) — STANDARD = 86400 (24 ชม.)
IsActiveboolfalse = ปิด flow ไม่รับสมัครใหม่
AllowSelfStartbooltrue = user เริ่มเองได้ โดยไม่ต้องรอ invitation
RefNoIssueAtstring?OnEnterDraft หรือ OnEnterSubmitted — เวลาที่ออก RefNo
DocumentConfigCodestring?config code สำหรับ PDF generation
StaleDaysBeforeAbandonint?จำนวนวันที่ไม่มี activity ก่อน abandon อัตโนมัติ
SpawnsCustomerFlowCodestring?flow code ที่สร้างให้ Customer อัตโนมัติหลัง Requestor Finalize
StaleSubmittedWarnDaysint?แจ้งเตือนก่อน abandon กี่วัน
RequestorRoleCodestring?role ที่ต้องมีถึงจะ start flow นี้ได้
AuthRequirementenum?Any / RequiresLogin / RequiresAnonymous
LoggedInRedirectFlowCodestring?redirect ไปที่ flow นี้ถ้า user login อยู่แล้ว
OnExistingAccountPolicyenum?Allow / RequireLogin — กรณี user มี account อยู่แล้ว
CreatedAt / UpdatedAtDateTime / DateTime?audit

FlowStep — รายการ step แต่ละตัวใน FlowDefinition เรียงตาม Order

เมื่อสร้าง FlowInstance ระบบดึง FlowStep เหล่านี้มา snapshot ไว้ใน FlowSnapshotJson เพื่อล็อกลำดับ ณ เวลานั้น

ColumnTypeคำอธิบาย
IdGuidPK
FlowDefinitionIdGuidFK → FlowDefinition
StepCatalogIdGuidFK → StepCatalog
StepTypestringdenormalized จาก StepCatalog.StepTypeCode — ใช้แทน join
Orderintลำดับ step ใน flow (0-based) — STANDARD: Consent=0, NDID=1, PersonalInfo=2
CanGoBackbooltrue = user กด Back ย้อนกลับไป step ก่อนหน้าได้
CreatedAtDateTimeaudit

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 ปัจจุบัน, เลขใบสมัคร

ColumnTypeคำอธิบาย
IdGuidPK
RefNostring?เลขอ้างอิงใบสมัคร เช่น ONB-2026-00001 — null จนกว่า background job จะ assign
FlowDefinitionIdGuidlogical ref ไปหา FlowDefinition — ไม่มี FK constraint (ดู snapshot แทน)
FlowSnapshotJsonstringjsonb snapshot ของ FlowDefinition+Steps ณ ตอนสร้าง — ล็อก step order ตลอด flow
StatusenumDraft / Submitted / Resubmitted / InReview / Reviewed / InApprove / Approved / Rejected / Rework / Finalized / Abandoned / Cancelled
OwnerUserIdGuid?cross-context User.Id — null ระหว่าง anonymous phase (ก่อนยืนยัน OTP)
CompanyIdGuid?cross-context Company.Id — set หลัง PersonalInfo step เสร็จ
ServiceIdGuid?cross-context Codex Service — ไม่มี FK
CurrentStepIndexintindex ของ step ที่กำลังทำอยู่ (0-based ตาม snapshot)
StuckAtStepTypestring?step ที่ automatic engine ติดค้างอยู่ — รอ retry
CompletedLockedStepsJsonstringjsonb list ของ step types ที่ lock แล้ว เช่น ["NdidStep"]
StepAttemptsJsonstringjsonb retry count ต่อ step เช่น {"NdidStep": 2}
RefNoPendingbooltrue = รอ background job assign RefNo อยู่
Archivedbooltrue = ซ่อนจาก default list
ArchivedAtDateTime?เวลา archive
SubmittedAtDateTime?เวลา submit ครั้งแรก
FinalizedAtDateTime?เวลา finalize
CancelledByenum?Owner / Admin — ใคร cancel
CancelReasonstring?เหตุผล cancel
CreatedAt / CreatedByDateTime / stringaudit
UpdatedAt / UpdatedByDateTime? / string?audit

FlowInstanceStepData — ข้อมูลที่ user กรอกในแต่ละ step บันทึกเป็น JSON ต่อ step

Composite PK (FlowInstanceId, StepType) — 1 step = 1 row ต่อ instance ถ้า step ถูก skip ระบบ copy ข้อมูลจาก UserStepRecord มาใส่ (IsSkipped = true) user ไม่ต้องกรอกใหม่

ColumnTypeคำอธิบาย
FlowInstanceIdGuidPK + FK → FlowInstance
StepTypestringPK — ชนิดของ step เช่น PersonalInfoStep
DataJsonstring?jsonb ข้อมูลที่ user กรอก / ระบบได้รับจาก step engine
IsSkippedbooltrue = copy มาจาก UserStepRecord user ไม่ต้องกรอกใหม่
CompletedAtDateTime?เวลาที่ step เสร็จสมบูรณ์
CreatedAt / CreatedByDateTime / stringaudit
UpdatedAt / UpdatedByDateTime? / 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

ColumnTypeคำอธิบาย
IdGuidPK
FlowInstanceIdGuidFK → FlowInstance
Sequenceintเลขลำดับ monotonic ต่อ instance — UNIQUE (FlowInstanceId, Sequence)
FromStatusenumstatus ก่อนเปลี่ยน
ToStatusenumstatus หลังเปลี่ยน
ActionenumSubmit / Resubmit / Approve / Reject / Rework / Finalize / Cancel / Abandon
ActorTypeenumOwner / Admin / System
ActorIdGuid?user id ของคนที่กระทำ
ActorNamestring?ชื่อของคนที่กระทำ (scalar copy ณ เวลานั้น)
ActorEmployeeIdRawstring?employee id (raw string, cross-context)
DecidedByContextJsonstring?jsonb — context ของคณะที่ approve/reject เช่น ชื่อ approver
Reasonstring?เหตุผล reject / cancel (ถ้ามี)
ReworkContextJsonstring?jsonb — step ที่ขอ rework + note per step
OccurredAtDateTimeเวลาที่เกิด event
SubmissionNoint?ครั้งที่ submit (1, 2, 3…) — null ถ้าไม่ใช่ submission row
IsSubmissionRowbooltrue = row นี้คือ submit event → จะมี HistoryStep rows freeze ไว้
RefNostring?scalar copy ณ เวลานั้น
CompanyIdGuid?scalar copy ณ เวลานั้น
OwnerUserIdGuid?scalar copy ณ เวลานั้น
CurrentStepIndexintscalar copy ณ เวลานั้น

FlowInstanceHistoryStep — snapshot ของ step data ณ ตอนที่ user กด Submit “freeze” ค่าไว้ตลอดกาล

มีเฉพาะใน submission rows (IsSubmissionRow = true) เช่น Draft→Submitted หรือ Rework→Submitted ใช้แสดงว่า submit รอบที่ N user กรอกอะไรไว้ — ถ้า IsSensitive = true (เช่น NdidStep) API ซ่อน DataJson

ColumnTypeคำอธิบาย
IdGuidPK
FlowInstanceHistoryIdGuidFK → FlowInstanceHistory (CASCADE delete)
StepTypestringชนิด step — UNIQUE (FlowInstanceHistoryId, StepType)
StepOrderintลำดับ step ใน snapshot นั้น
DataJsonstring?jsonb ข้อมูล step ณ ตอน submit (frozen ไม่เคยเปลี่ยน)
IsSensitivebooltrue = API ซ่อน DataJson ตอน review (เช่น NDID identity)
IsSkippedbooltrue = step นี้ถูก skip มา copy จาก UserStepRecord
CompletedAtDateTime?เวลาที่ step เสร็จ

Diagnostics

FlowInvestigationEvent — log ทุกครั้งที่ step ติด, retry, หรือ failed — append-only, ไม่มี PII

ColumnTypeคำอธิบาย
IdGuidPK
FlowInstanceIdGuidFK → FlowInstance
EventTypestringSTEP_STUCK / STEP_RETRIED / STEP_FAILED
StepTypestring?step ที่มีปัญหา
ErrorCodestring?error code จาก step engine
Detailstring?รายละเอียดทางเทคนิค (ห้าม PII เด็ดขาด)
AttemptNoint?ครั้งที่เกิด (1, 2, 3…)
CorrelationIdstring?request id สำหรับ trace log ข้าม service
CreatedAtDateTimeเวลา event

Correction / Rework

CorrectionRequest — บันทึกทุกครั้งที่ Approver ขอให้ Requestor กลับไปแก้ข้อมูลในบาง step

1 row = 1 ครั้งที่ขอแก้ append-only ไม่ลบ ApprovalDecisionId (Unique) ป้องกัน duplicate กรณี message ถูกส่งซ้ำจาก message broker (idempotency)

ColumnTypeคำอธิบาย
IdGuidPK
FlowInstanceIdGuidFK → FlowInstance
ApprovalDecisionIdGuidUK — idempotency key กัน duplicate จาก ASB retry
RequestedAtDateTimeเวลาที่ขอแก้
RequestedFromStatusenumstatus ก่อน Rework เช่น InApprove
RequestedByEmployeeIdGuid?employee id ของ Approver ที่ขอแก้
TargetStepsJsonstringjsonb: [{stepType, note, autoIncluded}] — step ที่ต้องแก้ + per-step note
DecidedByContextJsonstring?jsonb — context ของคณะผู้ตัดสิน
CreatedAt / CreatedByDateTime / stringaudit

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 ต่อจากเดิมได้

ColumnTypeคำอธิบาย
IdGuidPK
SessionIdstringUK — cookie value ที่ browser ถือ
FlowInstanceIdGuidFK → FlowInstance
UserIdGuid?null = anonymous phase; bind หลัง OTP สำเร็จ
Emailstringemail ของ user (empty ระหว่าง anonymous phase)
IsActiveboolfalse = session ถูกปิด (logout หรือ new session)
ExpiresAtDateTimeTTL — หมดแล้วสร้าง EditSession ใหม่ได้
CreatedAt / CreatedByDateTime / stringaudit
UpdatedAt / UpdatedByDateTime? / 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 ต่อบริษัท

ColumnTypeคำอธิบาย
IdGuidPK
Emailstringemail ของ user (link โดย email ไม่มี FK ไป FlowInstance)
StepTypestringชนิด step เช่น ConsentStep, PersonalInfoStep
CompletedAtDateTimeเวลาที่ทำ step เสร็จ
DataJsonstring?jsonb ข้อมูลที่ cache ไว้ — copy มาจาก FlowInstanceStepData
CompanyIdGuid?scope per company (optional)
ServiceIdGuid?scope per service (optional)
CreatedAt / CreatedByDateTime / stringaudit

Invitations

InvitationToken — token ที่ Admin สร้างและส่งให้ Customer ทาง email เป็น random string ธรรมดา (ไม่ใช่ JWT)

เมื่อ Customer กดลิงก์ในอีเมล (?code=<token>) ระบบ validate token นี้แล้วเริ่ม onboarding ให้อัตโนมัติ มี 2 nullable FK กลับ FlowInstance: ของ Requestor ที่สร้าง (OriginatingFlowInstanceId) และของ Customer ที่ใช้ (ConsumedByFlowInstanceId)

ColumnTypeคำอธิบาย
IdstringPK — opaque random token ส่งใน email link
Emailstringemail ของ Customer ที่รับ invitation
FlowCodestringflow ที่ Customer จะถูก start เมื่อใช้ token
StatusstringPENDINGCONSUMED / REVOKED / EXPIRED
CreatedByAdminIdGuidadmin user ที่สร้าง token
ExpiresAtDateTimetoken หมดอายุ
ConsumedAtDateTime?เวลาที่ Customer ใช้ token
CompanyIdGuid?บริษัทที่ token นี้ผูกอยู่
OriginatingFlowInstanceIdGuid?FK (nullable) → FlowInstance ของ Requestor
ConsumedByFlowInstanceIdGuid?FK (nullable) → FlowInstance ของ Customer
CreatedAt / CreatedByDateTime / stringaudit

Identity Bindings

CompanyRequestor — บันทึกว่าบริษัทนี้มี Requestor (คนยื่นเรื่องแทนบริษัท) คือใคร

UK บน CompanyId enforce 1 บริษัท = 1 Requestor เขียนตอน Finalize ของ Requestor flow สำเร็จ

ColumnTypeคำอธิบาย
IdGuidPK
CompanyIdGuidUK — cross-context Company.Id
RequestorUserIdGuidcross-context User.Id — ไม่มี FK
CreatedAtDateTimeเวลา bind

ServiceEnrollment — บันทึกว่า user enroll ใน service ใดแล้ว

ServiceId = cross-context Codex ServiceId — ไม่มี FK (Services table ถูก drop ใน migration 2026-05-28)

ColumnTypeคำอธิบาย
IdGuidPK
UserIdGuidcross-context User.Id
ServiceIdGuidcross-context Codex ServiceId
EnrolledAtDateTimeเวลา enroll
CreatedAt / CreatedByDateTime / stringaudit

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

TableIndexType
FlowInstanceOwnerUserIdBTree
FlowInstanceStatusBTree
FlowInstanceRefNoBTree
FlowInstance(CompanyId, ServiceId, Status)BTree composite
FlowInstanceRefNoPendingBTree
FlowInstanceHistoryFlowInstanceIdBTree
FlowInstanceHistory(FlowInstanceId, Sequence)UNIQUE
FlowInstanceHistoryStepFlowInstanceHistoryIdBTree
FlowInstanceHistoryStep(FlowInstanceHistoryId, StepType)UNIQUE
CorrectionRequestApprovalDecisionIdUNIQUE
CorrectionRequestFlowInstanceIdBTree
FlowInvestigationEventFlowInstanceIdBTree
EditSessionSessionIdUNIQUE
EditSessionFlowInstanceIdBTree
CompanyRequestorCompanyIdUNIQUE
CompanyRequestorRequestorUserIdBTree

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

BehaviorWhere
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 ใน DbContextFlowInstanceHistory
CorrectionRequest.TargetStepsJson รับทั้ง format ใหม่ [{stepType,note}] และ legacy ["A","B"]CorrectionRequest
ServiceId บน FlowDefinition และ FlowInstance = cross-context Codex Guid — ไม่มี FK constraintFK-less by design
OwnerUserId, CompanyId บน FlowInstance = cross-context — ไม่มี FK constraintFK-less by design
FlowInstanceHistory เขียนโดย domain event listener (FlowInstanceStatusChangedNotificationHandler) ที่ fire ตอน SaveChanges — ไม่ใช่ handler โดยตรง (ยกเว้น ApplyFlowProgress ที่เขียนตรง)FlowInstanceHistory
UserStepRecord เขียนตอน Finalize เท่านั้น (WriteLedgerAsync) — ไม่ได้เขียนทีละ stepUserStepRecord
ServiceEnrollment ไม่ได้เขียนใน onboarding flow handler — เขียนผ่าน async provisioning จาก Orchestrator หลัง FinalizeServiceEnrollment

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

TableOpค่าสำคัญ
FlowInstanceINSERTId=<A>, Status=Draft, OwnerUserId=null, CurrentStepIndex=0, FlowSnapshotJson=snapshot NEW_REQUESTOR+N steps
EditSessionINSERTSessionId=<cookie>, FlowInstanceId=<A>, Email="", ExpiresAt=now+SessionTtlSeconds

FlowInstanceHistory ยังไม่มี — Draft ไม่ fire status change event


2 — OtpVerificationStep: user กรอก email (ExecuteStepAction / SaveDraft)

TableOpค่าสำคัญ
FlowInstanceStepDataUPSERT(FlowInstanceId=<A>, StepType=OtpVerificationStep), DataJson={email:"somchai@example.com"}
EditSessionUPDATEEmail="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)

TableOpค่าสำคัญ
FlowInstanceStepDataUPDATEDataJson={email, oid:"<EntraOid>"}, CompletedAt=now
FlowInstanceUPDATEOwnerUserId=<UserId>, CurrentStepIndex=1

Entra identity ถูก deactivate (accountEnabled=false) — จะ activate อีกครั้งที่ Finalize (ADR-0034)


4 — Business steps: JuristicBinding → SignatoryForm (ExecuteStepAction / Next ×6)

Steps ที่เหลือก่อน Submit ทำ pattern เดียวกัน — แสดงรวมเป็น 1 กลุ่ม

TableOpค่าสำคัญ
FlowInstanceStepDataUPSERT per stepStepType = JuristicBindingStep / RequesterInfoStep / CompanyInfoStep / UserModeStep / DesignateCustomersStep / SignatoryFormStep ตามลำดับ
FlowInstanceUPDATE per stepCurrentStepIndex เพิ่มขึ้นทีละ 1

5 — FxRequestorTcConsentStep: กด Next = Submit (ExecuteStepAction / Submit)

Step สุดท้ายของ interactive flow — action Next บน step นี้ถูก engine map เป็น Submit path (FxRequestorTcConsentStep รวม T&C consent + dependency check ก่อน submit)

TableOpค่าสำคัญ
FlowInstanceStepDataUPSERT(FlowInstanceId=<A>, StepType=FxRequestorTcConsentStep), DataJson={agreed:true, tcVersion:...}, CompletedAt=now
FlowInstanceUPDATEStatus=Submitted, SubmittedAt=now, RefNo="ONB-2026-XXXXX", RefNoIssuedAt=now
FlowInstanceHistoryINSERTSequence=1, FromStatus=Draft, ToStatus=Submitted, Action=Submit, IsSubmissionRow=true, SubmissionNo=1
FlowInstanceHistoryStepINSERT ×Nfreeze ทุก step ณ เวลา submit — OtpVerificationStep (IsSensitive=false), business steps ต่างๆ

FlowInstanceHistory + FlowInstanceHistoryStep เขียนโดย domain event listener ณ SaveChanges (atomic กับ status flip)


6 — ApplyFlowProgress: InReview → Reviewed → InApprove (3 hops)

TableOpค่าสำคัญ
FlowInstanceUPDATE ×3Status: Submitted→InReview, InReview→Reviewed, Reviewed→InApprove
FlowInstanceHistoryINSERT ×3Seq 2–4, Action=InReview/Reviewed/InApprove, IsSubmissionRow=false (ไม่มี HistoryStep)

7 — Approve → Finalize อัตโนมัติ (TransitionFlowInstance[Approve])

TableOpค่าสำคัญ
CompanyUPSERTสร้าง/อัปเดต Company จากข้อมูล CompanyInfoStep
FlowInstanceUPDATEStatus=Approved, CompanyId=<CompanyId>
CompanyRequestorINSERT(CompanyId=<CompanyId>, RequestorUserId=<UserId>) — idempotent
CompanyApplicationINSERT(CompanyId, ServiceId) — ถ้ายังไม่มี
FlowInstanceUPDATEStatus=Finalized, FinalizedAt=now (auto-chain หลัง Approve)
UserStepRecordINSERT ×Ncache step ที่ eligible สำหรับ skip ในรอบถัดไป — เขียนตอน Finalize (WriteLedgerAsync)
FlowInstanceHistoryINSERTSequence=5, Action=Approve, IsSubmissionRow=false
FlowInstanceHistoryINSERTSequence=6, Action=Finalize, IsSubmissionRow=false

Approve + Finalize fire domain event ใน SaveChanges เดียวกัน → 2 History rows พร้อมกัน


สรุปสุดท้าย — สิ่งที่อยู่ใน DB หลัง Happy Path เสร็จ

TableRowsหมายเหตุ
FlowInstance1Status=Finalized, OwnerUserId=<UserId>, CompanyId=<CompanyId>
EditSession1ยังคงอยู่ (expired ตาม TTL เอง, ไม่ลบ)
FlowInstanceStepDataNOtpVerification + business steps ทั้งหมด
FlowInstanceHistory6Seq 1–6: Submit, InReview, Reviewed, InApprove, Approve, Finalize
FlowInstanceHistoryStepNfreeze ของ Submit (Seq=1) เท่านั้น
CompanyRequestor1ผูก Company ↔ Requestor
CompanyApplication1ผูก Company ↔ Service
UserStepRecordNcache step ที่ eligible สำหรับ skip ในรอบถัดไป

ServiceEnrollment ไม่มีใน rows ด้านบน — เขียนแยกผ่าน Orchestrator async provisioning


Rework Path — ต่างจาก Happy Path อย่างไร

เมื่อ Approver ขอแก้แทนที่จะ Approve (TransitionFlowInstance[Rework]):

TableOpค่าสำคัญ
CorrectionRequestINSERTTargetStepsJson=[{stepType, note}] step ที่ต้องแก้
FlowInstanceUPDATEStatus=Rework
FlowInstanceHistoryINSERTSequence=5, Action=Rework, IsSubmissionRow=false

เมื่อ User กด Submit อีกครั้ง (Resubmit) หลังแก้ข้อมูล:

TableOpค่าสำคัญ
FlowInstanceStepDataUPSERTอัปเดต step ที่แก้ใหม่
FlowInstanceUPDATEStatus=Resubmitted
FlowInstanceHistoryINSERTSequence=6, IsSubmissionRow=true, SubmissionNo=2
FlowInstanceHistoryStepINSERT ×Nfreeze ชุดที่ 2 ณ resubmit ครั้งนี้ (ประวัติแยกจากชุดแรก)