Private Docs

Plan — UserService (Lego) Implementation

แผน implement รายละเอียดฝั่ง Lego Engine สำหรับ orchestration integration

อัปเดต: 2026-06-15

For agentic workers: REQUIRED SUB-SKILL: Use superpowers

(recommended) or superpowers
to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: ทำให้ Lego Engine (UserService) ทำหน้าที่ mirror ของ Orchestrator saga ในช่วง in-flight (เพิ่ม status InReview/Reviewed/Resubmitted/Closed, progress consumer, validator widen, CANCEL wire, cancel publisher + sweep, requestor notification ครบ Approve/Reject/Rework/Cancel) ตาม contract ใน 00-OVERVIEW.md.

Architecture: Lego เป็นเจ้าของ status เองเฉพาะ Draft + Submitted/Resubmitted (กดปุ่ม) + automation chain หลัง Approved; ช่วง in-flight (InReview/Reviewed) รับผ่าน progress channel ใหม่ (topic flow-progress-update, idempotent + ordering guard, ไม่ผ่าน validator) ส่วน decision (Approve/Reject/Rework/Cancel) รับผ่าน flow-approval-result เดิม (ผ่าน validator ที่ขยาย in-flight set). Cancel = optimistic local + publish flow-cancel-requested ให้ orchestrator fan-out teardown. Notification หา requestor เป็นของ Lego (ถือ email + deep link).

Tech Stack: .NET Clean Architecture (01.API/02.Infrastructure/03.Application/04.Domain), EF Core, Azure Service Bus, xUnit + Moq


⚠️ Assumptions ที่ต้องยืนยันก่อนเริ่ม (ผู้เขียนแผน flag)

  1. Progress metadata persistence ขัดกับ “no migration”: เอกสาร 01 §L2 บอกเก็บ LastProgressAt + AssignedMakerUsername + AssignedReviewerUsername ใน FlowSnapshotJson (jsonb เดิม) เพื่อเลี่ยง migration — แต่ code จริง FlowInstance.FlowSnapshotJson เป็น immutable snapshot (D-V2-4, set ครั้งเดียวตอน Create, private setter) การเขียนทับจะทำลาย step snapshot. แผนนี้เลือก resolution ที่ถูกต้องเชิง domain: เพิ่ม jsonb column ใหม่ ProgressMetadataJson (default "{}") — ซึ่ง เป็น migration จริง 1 ตัว ทำบน MIGRATION branch ตาม [MIGRATION-branch workflow memory] แล้ว merge เข้า feature. status enum (L1) ยังคง no-migration จริง (string column HasMaxLength(30)). → ก่อนเริ่ม Task 2 ต้องยืนยันกับ user ว่ารับ migration 1 ตัวนี้ หรือจะเก็บ metadata เป็น in-memory-only (ไม่ persist ข้าม restart). แผนนี้เดินด้วยทางเลือก persist-via-new-column.
  2. InReview มีที่มาจริงไหม = open item รอทีม Workflow (มี pickup/claim ไหม) (CONTEXT.md Open item). แผนนี้ implement ฝั่ง Lego ให้ รับ progress ได้ทั้ง InReview และ Reviewed; ถ้า Workflow ไม่มี pickup orchestrator แค่ไม่เคยส่ง InReview — code ฝั่ง Lego ไม่ต้องแก้.
  3. Topic/subscription names ของ flow-progress-update + flow-cancel-requested ใช้ค่าตาม contract matrix (00 §7.1). subscription ฝั่ง Lego ตั้งชื่อตาม pattern เดิม userservice-<topic>.
  4. flow-progress-update payload progressStatus เป็น string "InReview"/"Reviewed" (ตรง enum name) ตาม 00 §7.2.

File Structure

ไฟล์รับผิดชอบTask
UserService04.Domain/Ports/Enums/FlowInstanceStatus.cs+4 enum valueT1
UserService04.Domain/Entities/Onboarding/FlowInstance.csApplyProgress, resubmit landing, IsActiveSlot, progress metadata accessorT2,T3,T4
UserService02.Infrastructure/Persistence/Configurations/Onboarding/FlowInstanceConfiguration.csmap ProgressMetadataJson jsonbT2
UserService03.Application/.../Engine/FlowTransitionValidator.cswiden in-flight setT5
UserService03.Application/.../Engine/ApprovalWireMapper.cs+CANCEL caseT6
UserService02.Infrastructure/Messaging/FlowProgressUpdateConsumer.cs (ใหม่)consume progress channelT7
UserService04.Domain/Ports/Onboarding/IFlowCancelRequestedPublisher.cs (ใหม่)port + event payloadT8
UserService02.Infrastructure/Messaging/AsbFlowCancelRequestedPublisher.cs (ใหม่)ASB publisher + Null fallbackT8
UserService03.Application/.../Commands/TransitionFlowInstance/TransitionFlowInstanceCommand.csCancelAsync publish + Reject/Cancel notificationT9,T11
UserService04.Domain/Ports/Onboarding/IFlowStatusNotificationPublisher.cs+PublishRejected/CancelledT11
UserService02.Infrastructure/Messaging/AsbFlowStatusNotificationPublisher.csimpl 2 method ใหม่ + NullT11
UserService02.Infrastructure/Onboarding/FlowInstanceOrphanCleanupService.cspost-submit sweep → Cancel+publishT10
UserService02.Infrastructure/DependencyInjection.csregister publisher + consumerT8,T7

คำสั่ง run test (ทุก task ใช้ตัวนี้ — ปรับ --filter):

dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~<TestClassOrMethod>"

Full suite (regression gate ≥90%, onboarding 94/94 เดิม):

dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj

Task 1 — เพิ่ม status enum ใหม่ (no migration) [L1]

Files:

  • Modify: C:/Source/AzureDevOps_SuperAPP/Backend_UserService/src/UserService04.Domain/Ports/Enums/FlowInstanceStatus.cs

  • Test: C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs

  • Step 1: Write failing test — เพิ่มใน FlowInstanceStateMachineTest:

    [Theory]
    [InlineData(FlowInstanceStatus.InReview, 8)]
    [InlineData(FlowInstanceStatus.Reviewed, 9)]
    [InlineData(FlowInstanceStatus.Resubmitted, 10)]
    [InlineData(FlowInstanceStatus.Closed, 11)]
    public void New_orchestration_status_values_have_stable_ordinals(FlowInstanceStatus s, int expected)
        => Assert.Equal(expected, (int)s);
  • Step 2: Run → FAIL (compile error: InReview not defined)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~New_orchestration_status_values_have_stable_ordinals"

Expected: build error 'FlowInstanceStatus' does not contain a definition for 'InReview'

  • Step 3: Implement — แก้ FlowInstanceStatus.cs เพิ่มหลัง Cancelled = 7,:
    /// <summary>Maker หยิบงานจากกองกลาง (orchestrator project ผ่าน progress channel) — mirror, in-flight</summary>
    InReview = 8,

    /// <summary>Maker ส่งให้ Reviewer พิจารณา (orchestrator project) — mirror, in-flight</summary>
    Reviewed = 9,

    /// <summary>owner กด Resubmit จาก Rework — Lego set เอง (local, symmetric กับ Submitted), in-flight</summary>
    Resubmitted = 10,

    /// <summary>เผื่ออนาคต — ยังไม่ wire</summary>
    Closed = 11,
  • Step 4: Run → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~New_orchestration_status_values_have_stable_ordinals"

Expected: PASS (1 passed)

  • Step 5: Commit
git add src/UserService04.Domain/Ports/Enums/FlowInstanceStatus.cs tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs
git commit -m "feat(lego): add InReview/Reviewed/Resubmitted/Closed flow statuses (no migration)"

Task 2 — ProgressMetadataJson column + ApplyProgress() [L2 progress part]

⚠️ ดู Assumption #1 — task นี้เพิ่ม jsonb column ใหม่ (migration บน MIGRATION branch). ทำ schema config ที่นี่; migration file สร้างแยกบน MIGRATION branch (ดู Step 6).

Files:

  • Modify: .../UserService04.Domain/Entities/Onboarding/FlowInstance.cs

  • Modify: .../UserService02.Infrastructure/Persistence/Configurations/Onboarding/FlowInstanceConfiguration.cs

  • Test: .../tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs

  • Step 1: Write failing test

    [Fact]
    public void ApplyProgress_sets_status_maker_and_lastProgressAt()
    {
        var fi = NewInstance();
        fi.TransitionToSubmitted("t");
        var t1 = DateTime.UtcNow;
        fi.ApplyProgress(FlowInstanceStatus.InReview, "maker.a", t1, "t");
        Assert.Equal(FlowInstanceStatus.InReview, fi.Status);
        Assert.Equal("maker.a", fi.AssignedMakerUsername);
        Assert.Equal(t1, fi.LastProgressAt);
    }

    [Fact]
    public void ApplyProgress_to_Reviewed_sets_reviewer_keeps_maker()
    {
        var fi = NewInstance();
        fi.TransitionToSubmitted("t");
        fi.ApplyProgress(FlowInstanceStatus.InReview, "maker.a", DateTime.UtcNow, "t");
        fi.ApplyProgress(FlowInstanceStatus.Reviewed, "reviewer.b", DateTime.UtcNow.AddMinutes(1), "t");
        Assert.Equal(FlowInstanceStatus.Reviewed, fi.Status);
        Assert.Equal("maker.a", fi.AssignedMakerUsername);
        Assert.Equal("reviewer.b", fi.AssignedReviewerUsername);
    }
  • Step 2: Run → FAIL (ApplyProgress / AssignedMakerUsername not defined)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~ApplyProgress"

Expected: build error does not contain a definition for 'ApplyProgress'

  • Step 3: Implement — ใน FlowInstance.cs:

(3a) เพิ่ม field + accessor หลัง StepAttemptsJson property (~line 44):

    /// <summary>JSON { lastProgressAt, assignedMakerUsername, assignedReviewerUsername } — orchestration progress mirror (mutable, แยกจาก immutable FlowSnapshotJson)</summary>
    public string ProgressMetadataJson { get; private set; } = "{}";

    public DateTime? LastProgressAt => ParseProgress().LastProgressAt;
    public string? AssignedMakerUsername => ParseProgress().AssignedMakerUsername;
    public string? AssignedReviewerUsername => ParseProgress().AssignedReviewerUsername;

    private sealed record ProgressMetadata(
        DateTime? LastProgressAt = null,
        string? AssignedMakerUsername = null,
        string? AssignedReviewerUsername = null);

    private ProgressMetadata ParseProgress()
    {
        try { return JsonSerializer.Deserialize<ProgressMetadata>(ProgressMetadataJson) ?? new(); }
        catch { return new(); }
    }

(3b) เพิ่ม method ใกล้ TransitionToSubmitted (~line 175):

    /// <summary>
    /// [Orchestration] mirror progress projection จาก saga (InReview/Reviewed) — status-only + track 2-actor.
    /// idempotency/ordering/terminal guard ทำที่ consumer (ไม่ใช่ entity); entity แค่ apply.
    /// </summary>
    public void ApplyProgress(FlowInstanceStatus target, string? actorUsername, DateTime occurredAt, string updatedBy)
    {
        var cur = ParseProgress();
        var next = target == FlowInstanceStatus.InReview
            ? cur with { LastProgressAt = occurredAt, AssignedMakerUsername = actorUsername ?? cur.AssignedMakerUsername }
            : cur with { LastProgressAt = occurredAt, AssignedReviewerUsername = actorUsername ?? cur.AssignedReviewerUsername };
        ProgressMetadataJson = JsonSerializer.Serialize(next);

        var from = Status;
        Status = target;
        RaiseStatusChanged(from, HistoryAction.Submit, TransitionActor.System);
        Touch(updatedBy);
    }

หมายเหตุ: ใช้ HistoryAction.Submit เป็น placeholder action สำหรับ progress mirror (ไม่มี action enum สำหรับ progress; status-only). ถ้าทีมต้องการ audit แยก ค่อยเพิ่ม HistoryAction.Progress ภายหลัง — YAGNI ตอนนี้.

(3c) ใน FlowInstanceConfiguration.cs เพิ่มหลัง StepAttemptsJson mapping:

        builder.Property(x => x.ProgressMetadataJson).HasColumnType("jsonb").IsRequired();
  • Step 4: Run → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~ApplyProgress"

Expected: PASS (2 passed)

  • Step 5: Commit
git add src/UserService04.Domain/Entities/Onboarding/FlowInstance.cs src/UserService02.Infrastructure/Persistence/Configurations/Onboarding/FlowInstanceConfiguration.cs tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs
git commit -m "feat(lego): add ApplyProgress + ProgressMetadataJson for 2-actor progress mirror"
  • Step 6: Migration (แยก branch) — บน MIGRATION branch (ไม่ใช่ feature):
git checkout MIGRATION && git merge feature/sprint9/SA-1557 --no-edit
dotnet ef migrations add AddFlowInstanceProgressMetadata --project src/UserService02.Infrastructure --startup-project src/UserService01.API

ตรวจ Up(): ต้องมีแค่ AddColumn<string>("ProgressMetadataJson", "FlowInstance", type:"jsonb", defaultValue:"{}")ไม่มี drift อื่น/HasData re-insert (ดู [EF snapshot drift memory]). ถ้า migration ออกว่าง = branch ไม่เห็น entity → ตรวจ ProjectReference. แล้ว merge MIGRATION → feature.


Task 3 — resubmit landing: Rework → Resubmitted [L2 resubmit part]

Files:

  • Modify: .../UserService04.Domain/Entities/Onboarding/FlowInstance.cs:166-175 (TransitionToSubmitted)

  • Test: .../tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs

  • Step 1: Write failing test

    [Fact]
    public void Resubmit_from_Rework_lands_on_Resubmitted_not_Submitted()
    {
        var fi = NewInstance();
        fi.TransitionToSubmitted("t");        // Draft → Submitted
        fi.TransitionToRework("t");           // Submitted → Rework
        fi.TransitionToSubmitted("t");        // Rework → Resubmitted
        Assert.Equal(FlowInstanceStatus.Resubmitted, fi.Status);
        Assert.NotNull(fi.SubmittedAt);
    }

    [Fact]
    public void First_submit_from_Draft_still_lands_on_Submitted()
    {
        var fi = NewInstance();
        fi.TransitionToSubmitted("t");
        Assert.Equal(FlowInstanceStatus.Submitted, fi.Status);
    }
  • Step 2: Run → FAIL
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~Resubmit_from_Rework_lands_on_Resubmitted_not_Submitted"

Expected: FAIL Expected Resubmitted, Actual Submitted

  • Step 3: Implement — แก้ TransitionToSubmitted (line 168-171):
        var from = Status;
        // Rework → Resubmitted (owner กด resubmit, in-flight ใหม่); อื่น → Submitted (first submit)
        var action = from == FlowInstanceStatus.Rework ? HistoryAction.Resubmit : HistoryAction.Submit;
        Status = from == FlowInstanceStatus.Rework ? FlowInstanceStatus.Resubmitted : FlowInstanceStatus.Submitted;
        SubmittedAt = DateTime.UtcNow;

(บรรทัด RaiseStatusChanged(from, action, ...) + Touch(updatedBy) เดิมคงไว้)

  • Step 4: Run → PASS (รวม regression: existing Submit_sets_SubmittedAt_and_status ต้องยังเขียว)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowInstanceStateMachineTest"

Expected: PASS (ทั้ง class)

  • Step 5: Commit
git add src/UserService04.Domain/Entities/Onboarding/FlowInstance.cs tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs
git commit -m "feat(lego): resubmit from Rework lands on Resubmitted status"

Task 4 — IsActiveSlot รวม in-flight ใหม่ [L3]

Files:

  • Modify: .../UserService04.Domain/Entities/Onboarding/FlowInstance.cs:254-257

  • Test: .../tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs

  • Step 1: Write failing test

    [Theory]
    [InlineData(FlowInstanceStatus.InReview, true)]
    [InlineData(FlowInstanceStatus.Reviewed, true)]
    [InlineData(FlowInstanceStatus.Resubmitted, true)]
    public void IsActiveSlot_includes_inflight_orchestration_statuses(FlowInstanceStatus status, bool expected)
    {
        var fi = NewInstance();
        fi.TransitionToSubmitted("t");
        fi.ApplyProgress(status == FlowInstanceStatus.Resubmitted ? FlowInstanceStatus.InReview : status,
            "x", DateTime.UtcNow, "t");
        if (status == FlowInstanceStatus.Resubmitted)
        {
            fi.TransitionToRework("t");
            fi.TransitionToSubmitted("t"); // → Resubmitted
        }
        Assert.Equal(expected, fi.IsActiveSlot());
    }
  • Step 2: Run → FAIL (InReview ไม่อยู่ใน active set → false)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~IsActiveSlot_includes_inflight_orchestration_statuses"

Expected: FAIL Expected True, Actual False

  • Step 3: Implement — แก้ IsActiveSlot():
    /// <summary>active = กิน uniqueness slot (D-V2-29) — รวม in-flight orchestration status</summary>
    public bool IsActiveSlot() =>
        Status is FlowInstanceStatus.Draft or FlowInstanceStatus.Submitted or FlowInstanceStatus.Approved
            or FlowInstanceStatus.Rework or FlowInstanceStatus.Finalized
            or FlowInstanceStatus.InReview or FlowInstanceStatus.Reviewed or FlowInstanceStatus.Resubmitted;
  • Step 4: Run → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~IsActiveSlot"

Expected: PASS (รวม theory เดิม IsActiveSlot_only_non_terminal... ยังเขียว)

  • Step 5: Commit
git add src/UserService04.Domain/Entities/Onboarding/FlowInstance.cs tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowInstanceStateMachineTest.cs
git commit -m "feat(lego): IsActiveSlot includes InReview/Reviewed/Resubmitted"

Task 5 — ขยาย FlowTransitionValidator (in-flight set) [L4]

Files:

  • Modify: .../UserService03.Application/Features/Onboarding/Engine/FlowTransitionValidator.cs

  • Test (ใหม่): .../tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowTransitionValidatorTest.cs

  • Step 1: Write failing test (ไฟล์ใหม่)

using UserService03.Application.Features.Onboarding.Engine;
using UserService04.Domain.Ports.Enums;
using Xunit;

namespace UserService05.Tests.Application.Features.Onboarding.Engine;

public class FlowTransitionValidatorTest
{
    [Theory]
    [InlineData(FlowInstanceStatus.Submitted)]
    [InlineData(FlowInstanceStatus.InReview)]
    [InlineData(FlowInstanceStatus.Reviewed)]
    [InlineData(FlowInstanceStatus.Resubmitted)]
    public void Decision_actions_allowed_from_inflight_set(FlowInstanceStatus current)
    {
        Assert.True(FlowTransitionValidator.IsAllowed(TransitionAction.Approve, current));
        Assert.True(FlowTransitionValidator.IsAllowed(TransitionAction.Reject, current));
        Assert.True(FlowTransitionValidator.IsAllowed(TransitionAction.Rework, current));
    }

    [Theory]
    [InlineData(FlowInstanceStatus.Submitted)]
    [InlineData(FlowInstanceStatus.InReview)]
    [InlineData(FlowInstanceStatus.Reviewed)]
    [InlineData(FlowInstanceStatus.Resubmitted)]
    [InlineData(FlowInstanceStatus.Rework)]
    public void Cancel_allowed_from_inflight_set_plus_rework(FlowInstanceStatus current)
        => Assert.True(FlowTransitionValidator.IsAllowed(TransitionAction.Cancel, current));

    [Theory]
    [InlineData(FlowInstanceStatus.Draft)]
    [InlineData(FlowInstanceStatus.Approved)]
    [InlineData(FlowInstanceStatus.Rejected)]
    [InlineData(FlowInstanceStatus.Finalized)]
    [InlineData(FlowInstanceStatus.Cancelled)]
    public void Decision_actions_rejected_outside_inflight(FlowInstanceStatus current)
    {
        Assert.False(FlowTransitionValidator.IsAllowed(TransitionAction.Approve, current));
        Assert.False(FlowTransitionValidator.IsAllowed(TransitionAction.Cancel, current));
    }

    [Fact]
    public void Rework_status_not_decision_target_but_cancellable()
    {
        Assert.False(FlowTransitionValidator.IsAllowed(TransitionAction.Approve, FlowInstanceStatus.Rework));
        Assert.True(FlowTransitionValidator.IsAllowed(TransitionAction.Cancel, FlowInstanceStatus.Rework));
    }
}
  • Step 2: Run → FAIL (เดิม Approve รับเฉพาะ SubmittedInReview คืน false)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowTransitionValidatorTest"

Expected: FAIL Decision_actions_allowed_from_inflight_set(InReview) → Expected True

  • Step 3: Implement — แก้ FlowTransitionValidator.cs:
public static class FlowTransitionValidator
{
    // in-flight = ช่วงรอ/ระหว่าง review ที่ saga คุม (Submitted + progress mirror + resubmit)
    private static bool IsInFlight(FlowInstanceStatus s) =>
        s is FlowInstanceStatus.Submitted or FlowInstanceStatus.InReview
            or FlowInstanceStatus.Reviewed or FlowInstanceStatus.Resubmitted;

    public static bool IsAllowed(TransitionAction action, FlowInstanceStatus current) => action switch
    {
        // approval outcomes รับจาก in-flight set (4-eye gate enforce ที่ saga ไม่ใช่ที่นี่ — ดู CONTEXT)
        TransitionAction.Approve or TransitionAction.Reject or TransitionAction.Rework
            => IsInFlight(current),
        // admin/sweep cancel post-submit: in-flight ∪ {Rework} (§6 post-submit cancel set)
        TransitionAction.Cancel
            => IsInFlight(current) || current == FlowInstanceStatus.Rework,
        _ => false,
    };
}
  • Step 4: Run → PASS (รวม regression FlowTransitionHandlerTest เดิม)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowTransitionValidatorTest|FullyQualifiedName~FlowTransitionHandlerTest"

Expected: PASS

  • Step 5: Commit
git add src/UserService03.Application/Features/Onboarding/Engine/FlowTransitionValidator.cs tests/UserService05.Tests/Application/Features/Onboarding/Engine/FlowTransitionValidatorTest.cs
git commit -m "feat(lego): widen FlowTransitionValidator to in-flight set (InReview/Reviewed/Resubmitted)"

Task 6 — ApprovalWireMapper +CANCEL [L6]

Files:

  • Modify: .../UserService03.Application/Features/Onboarding/Engine/ApprovalWireMapper.cs

  • Test: .../tests/UserService05.Tests/Application/Features/Onboarding/FlowTransitionHandlerTest.cs

  • Step 1: Write failing test — เพิ่ม InlineData ใน WireMapper_maps_legacy_result:

    [InlineData("CANCEL", TransitionAction.Cancel)]
    [InlineData("cancel", TransitionAction.Cancel)]

(เพิ่ม 2 บรรทัดนี้ใต้ [InlineData("correction_requested", TransitionAction.Rework)] ที่ line ~205)

  • Step 2: Run → FAIL
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~WireMapper_maps_legacy_result"

Expected: FAIL WireMapper_maps_legacy_result(CANCEL) (TryMapAction คืน false → Assert.True fails)

  • Step 3: Implement — เพิ่ม case ใน ApprovalWireMapper.TryMapAction ก่อน default::
            case "CANCEL": action = TransitionAction.Cancel; return true;
  • Step 4: Run → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~WireMapper_maps_legacy_result"

Expected: PASS (6 cases)

  • Step 5: Commit
git add src/UserService03.Application/Features/Onboarding/Engine/ApprovalWireMapper.cs tests/UserService05.Tests/Application/Features/Onboarding/FlowTransitionHandlerTest.cs
git commit -m "feat(lego): map CANCEL wire result to TransitionAction.Cancel"

หมายเหตุ: consumer (FlowApprovalResultConsumer) เรียก path cancel อัตโนมัติ — TransitionAction.Cancel → handler CancelAsync (teardown + Cancelled) อยู่แล้ว; validator (T5) รับ Cancel จาก in-flight แล้ว. ไม่ต้องแก้ consumer.


Task 7 — Progress consumer (topic flow-progress-update) [L5]

Files:

  • Create: .../UserService02.Infrastructure/Messaging/FlowProgressUpdateConsumer.cs
  • Modify: .../UserService02.Infrastructure/DependencyInjection.cs (register hosted service — 3 branch ASB)
  • Test (ใหม่): .../tests/UserService05.Tests/Infrastructure/Messaging/FlowProgressGuardTest.cs

consumer ใช้ IFlowInstanceRepository + IApprovalDecisionDedupeStore (reuse — keyed by Guid eventId) ผ่าน DI scope. Guard logic (terminal/ordering) แยกเป็น static FlowProgressGuard เพื่อ test ได้โดยไม่ต้อง spin ASB (consumer ทดสอบ infra เต็มไม่คุ้ม — test เฉพาะ guard, mirror style ที่ codebase แยก ApprovalWireMapper).

  • Step 1: Write failing test (ไฟล์ใหม่ — test guard ก่อน)
using UserService02.Infrastructure.Messaging;
using UserService04.Domain.Ports.Enums;
using Xunit;

namespace UserService05.Tests.Infrastructure.Messaging;

public class FlowProgressGuardTest
{
    [Theory]
    [InlineData(FlowInstanceStatus.Approved)]
    [InlineData(FlowInstanceStatus.Rejected)]
    [InlineData(FlowInstanceStatus.Cancelled)]
    [InlineData(FlowInstanceStatus.Finalized)]
    [InlineData(FlowInstanceStatus.Abandoned)]
    [InlineData(FlowInstanceStatus.Rework)]
    public void Terminal_or_rework_status_blocks_progress(FlowInstanceStatus current)
        => Assert.False(FlowProgressGuard.CanApply(current, lastProgressAt: null, occurredAt: DateTime.UtcNow));

    [Fact]
    public void Older_occurredAt_is_ignored()
    {
        var last = DateTime.UtcNow;
        Assert.False(FlowProgressGuard.CanApply(FlowInstanceStatus.Submitted, last, last.AddSeconds(-1)));
        Assert.False(FlowProgressGuard.CanApply(FlowInstanceStatus.InReview, last, last)); // equal = ignore
    }

    [Fact]
    public void Newer_occurredAt_on_inflight_applies_including_reviewer_bounce()
    {
        var last = DateTime.UtcNow;
        Assert.True(FlowProgressGuard.CanApply(FlowInstanceStatus.Submitted, null, last));
        Assert.True(FlowProgressGuard.CanApply(FlowInstanceStatus.Reviewed, last, last.AddSeconds(1))); // bounce Reviewed→InReview ok
    }
}
  • Step 2: Run → FAIL (FlowProgressGuard ไม่มี)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowProgressGuardTest"

Expected: build error FlowProgressGuard not found

  • Step 3: Implement — สร้าง FlowProgressUpdateConsumer.cs (รวม guard + consumer ในไฟล์เดียว, mirror FlowApprovalResultConsumer structure):
using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using UserService04.Domain.Ports.Enums;
using UserService04.Domain.Ports.Onboarding;
using UserService04.Domain.Ports.Persistence;

namespace UserService02.Infrastructure.Messaging;

/// <summary>terminal + ordering guard ของ progress mirror (แยก static เพื่อ test) — ไม่ผ่าน FlowTransitionValidator</summary>
public static class FlowProgressGuard
{
    public static bool CanApply(FlowInstanceStatus current, DateTime? lastProgressAt, DateTime occurredAt)
    {
        // terminal + Rework (owner กำลังแก้) → ไม่รับ progress (mirror หยุดเมื่อออกจาก in-flight)
        var blocked = current is FlowInstanceStatus.Approved or FlowInstanceStatus.Rejected
            or FlowInstanceStatus.Cancelled or FlowInstanceStatus.Finalized
            or FlowInstanceStatus.Abandoned or FlowInstanceStatus.Rework or FlowInstanceStatus.Closed;
        if (blocked) return false;
        // ordering: apply เฉพาะ occurredAt ใหม่กว่า (reviewer bounce = occurredAt ใหม่ → ผ่าน)
        return lastProgressAt is null || occurredAt > lastProgressAt.Value;
    }
}

/// <summary>
/// [Orchestration] Consume flow-progress-update (InReview/Reviewed projection จาก saga).
/// idempotent (eventId via dedupe store) + ordering/terminal guard (FlowProgressGuard) + status-only mirror.
/// ไม่ผ่าน FlowTransitionValidator, ไม่จุด automation. mirror FlowApprovalResultConsumer.
/// </summary>
public sealed class FlowProgressUpdateConsumer : BackgroundService
{
    private const string TopicName = "flow-progress-update";
    private const string SubscriptionName = "userservice-flow-progress-update";

    private readonly ServiceBusClient _client;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<FlowProgressUpdateConsumer> _logger;
    private ServiceBusProcessor? _processor;

    private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };

    public FlowProgressUpdateConsumer(
        ServiceBusClient client, IServiceScopeFactory scopeFactory, ILogger<FlowProgressUpdateConsumer> logger)
    {
        _client = client;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _processor = _client.CreateProcessor(TopicName, SubscriptionName, new ServiceBusProcessorOptions
        {
            MaxConcurrentCalls = 1,
            AutoCompleteMessages = false,
        });
        _processor.ProcessMessageAsync += OnMessageAsync;
        _processor.ProcessErrorAsync += OnErrorAsync;
        await _processor.StartProcessingAsync(stoppingToken);
        _logger.LogInformation("FlowProgressUpdateConsumer started on {Topic}/{Sub}", TopicName, SubscriptionName);
    }

    private async Task OnMessageAsync(ProcessMessageEventArgs args)
    {
        try
        {
            var p = JsonSerializer.Deserialize<ProgressMessage>(args.Message.Body.ToString(), JsonOptions);
            if (p is null || p.FlowInstanceId == Guid.Empty || p.EventId == Guid.Empty
                || !Enum.TryParse<FlowInstanceStatus>(p.ProgressStatus, out var target)
                || target is not (FlowInstanceStatus.InReview or FlowInstanceStatus.Reviewed))
            {
                _logger.LogWarning("Invalid flow-progress-update (status={Status}) — dead-letter", p?.ProgressStatus);
                await args.DeadLetterMessageAsync(args.Message, "InvalidPayload");
                return;
            }

            using var scope = _scopeFactory.CreateScope();
            var dedupe = scope.ServiceProvider.GetRequiredService<IApprovalDecisionDedupeStore>();
            // idempotent: eventId ซ้ำ → no-op
            if (!await dedupe.TryMarkProcessedAsync(p.EventId))
            {
                await args.CompleteMessageAsync(args.Message);
                return;
            }

            var repo = scope.ServiceProvider.GetRequiredService<IFlowInstanceRepository>();
            var instance = await repo.GetByIdAsync(p.FlowInstanceId);
            if (instance is null)
            {
                _logger.LogWarning("flow-progress-update: instance {Id} not found — complete (no retry)", p.FlowInstanceId);
                await args.CompleteMessageAsync(args.Message);
                return;
            }

            if (FlowProgressGuard.CanApply(instance.Status, instance.LastProgressAt, p.OccurredAt))
            {
                instance.ApplyProgress(target, p.ActorUsername, p.OccurredAt, "orchestrator-progress");
                await repo.UpdateAsync(instance);
                _logger.LogDebug("Applied progress {Status} to {Id}", target, p.FlowInstanceId);
            }
            else
            {
                _logger.LogDebug("Ignored stale/terminal progress {Status} for {Id} (cur={Cur})", target, p.FlowInstanceId, instance.Status);
            }

            await args.CompleteMessageAsync(args.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing flow-progress-update — abandon for retry");
            await args.AbandonMessageAsync(args.Message);
        }
    }

    private Task OnErrorAsync(ProcessErrorEventArgs args)
    {
        _logger.LogError(args.Exception, "FlowProgressUpdateConsumer error: {Source}", args.ErrorSource);
        return Task.CompletedTask;
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_processor is not null)
        {
            await _processor.StopProcessingAsync(cancellationToken);
            await _processor.DisposeAsync();
        }
        await base.StopAsync(cancellationToken);
    }

    private sealed class ProgressMessage
    {
        public Guid EventId { get; set; }
        public Guid FlowInstanceId { get; set; }
        public string? RefNo { get; set; }
        public string? ProgressStatus { get; set; }   // "InReview" | "Reviewed"
        public string? ActorUsername { get; set; }     // maker (InReview) / reviewer (Reviewed)
        public DateTime OccurredAt { get; set; }
    }
}
  • Step 4: Run guard test → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowProgressGuardTest"

Expected: PASS

  • Step 5: Register consumer — ใน DependencyInjection.cs เพิ่มใต้ services.AddHostedService<Messaging.FlowApprovalResultConsumer>(); ทั้ง 2 branch ASB (line ~477 และ ~493):
                services.AddHostedService<Messaging.FlowProgressUpdateConsumer>();

(branch else ที่ไม่มี ASB — ไม่ register, ตาม pattern เดิม)

  • Step 6: Run full build/test (regression)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~Onboarding"

Expected: PASS (no regression)

  • Step 7: Commit
git add src/UserService02.Infrastructure/Messaging/FlowProgressUpdateConsumer.cs src/UserService02.Infrastructure/DependencyInjection.cs tests/UserService05.Tests/Infrastructure/Messaging/FlowProgressGuardTest.cs
git commit -m "feat(lego): add flow-progress-update consumer (idempotent + ordering/terminal guard)"

Task 8 — Cancel publisher (port + ASB impl + DI) [L7]

Files:

  • Create: .../UserService04.Domain/Ports/Onboarding/IFlowCancelRequestedPublisher.cs

  • Create: .../UserService02.Infrastructure/Messaging/AsbFlowCancelRequestedPublisher.cs

  • Modify: .../UserService02.Infrastructure/DependencyInjection.cs (register 3 branch)

  • Test (ใหม่): .../tests/UserService05.Tests/Infrastructure/Messaging/NullFlowCancelRequestedPublisherTest.cs

  • Step 1: Write failing test (test Null publisher = ไม่ throw — mirror style; ASB ไม่ unit-test)

using Microsoft.Extensions.Logging.Abstractions;
using UserService02.Infrastructure.Messaging;
using UserService04.Domain.Ports.Onboarding;
using Xunit;

namespace UserService05.Tests.Infrastructure.Messaging;

public class NullFlowCancelRequestedPublisherTest
{
    [Fact]
    public async Task Null_publisher_does_not_throw()
    {
        var pub = new NullFlowCancelRequestedPublisher(NullLogger<NullFlowCancelRequestedPublisher>.Instance);
        var evt = new FlowCancelRequestedEvent(Guid.NewGuid(), DateTime.UtcNow, Guid.NewGuid(),
            "FX-1", CancelInitiatedBy.Lego, CancelMode.Manual, "test");
        await pub.PublishCancelRequestedAsync(evt);
        Assert.Equal(CancelInitiatedBy.Lego, evt.InitiatedBy);
    }
}
  • Step 2: Run → FAIL (type ไม่มี)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~NullFlowCancelRequestedPublisherTest"

Expected: build error FlowCancelRequestedEvent not found

  • Step 3a: Implement portIFlowCancelRequestedPublisher.cs:
namespace UserService04.Domain.Ports.Onboarding;

/// <summary>ใครเริ่ม cancel — fan-out idempotent ที่ orchestrator (00 §6)</summary>
public enum CancelInitiatedBy { Lego, Workflow }

/// <summary>โหมด cancel — Manual (admin/owner) หรือ Expiry (sweep)</summary>
public enum CancelMode { Manual, Expiry }

/// <summary>
/// [Orchestration] Publish flow-cancel-requested ตอน Lego cancel post-submit (admin/sweep)
/// → orchestrator OnCancel fan-out teardown Task/Workflow. fire-and-forget (ไม่ throw).
/// </summary>
public interface IFlowCancelRequestedPublisher
{
    Task PublishCancelRequestedAsync(FlowCancelRequestedEvent evt, CancellationToken ct = default);
}

/// <summary>Payload flow-cancel-requested (00 §7.1 M2). ไม่ต้องมี requestorUserId (orchestrator ไม่ notify)</summary>
public sealed record FlowCancelRequestedEvent(
    Guid EventId,
    DateTime OccurredAt,
    Guid FlowInstanceId,
    string? RefNo,
    CancelInitiatedBy InitiatedBy,
    CancelMode Mode,
    string? Reason);
  • Step 3b: Implement ASB publisherAsbFlowCancelRequestedPublisher.cs (mirror AsbFlowApprovalEventPublisher):
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.Logging;
using UserService04.Domain.Ports.Onboarding;

namespace UserService02.Infrastructure.Messaging;

/// <summary>[Orchestration] Publish flow-cancel-requested. fire-and-forget — never throw.</summary>
public sealed class AsbFlowCancelRequestedPublisher : IFlowCancelRequestedPublisher, IAsyncDisposable
{
    private const string TopicName = "flow-cancel-requested";
    private readonly ServiceBusSender _sender;
    private readonly ILogger<AsbFlowCancelRequestedPublisher> _logger;

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Converters = { new JsonStringEnumConverter() },
    };

    public AsbFlowCancelRequestedPublisher(ServiceBusClient client, ILogger<AsbFlowCancelRequestedPublisher> logger)
    {
        _sender = client.CreateSender(TopicName);
        _logger = logger;
    }

    public async Task PublishCancelRequestedAsync(FlowCancelRequestedEvent evt, CancellationToken ct = default)
    {
        try
        {
            var json = JsonSerializer.Serialize(evt, JsonOptions);
            var msg = new ServiceBusMessage(json)
            {
                ContentType = "application/json",
                MessageId = evt.EventId.ToString(),
                Subject = TopicName,
            };
            await _sender.SendMessageAsync(msg, ct);
            _logger.LogDebug("Published flow-cancel-requested: FlowInstanceId={Id}", evt.FlowInstanceId);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to publish flow-cancel-requested: {Id}", evt.FlowInstanceId);
        }
    }

    public async ValueTask DisposeAsync() => await _sender.DisposeAsync();
}

/// <summary>Fallback เมื่อไม่มี ASB (local dev)</summary>
public sealed class NullFlowCancelRequestedPublisher(ILogger<NullFlowCancelRequestedPublisher> logger)
    : IFlowCancelRequestedPublisher
{
    public Task PublishCancelRequestedAsync(FlowCancelRequestedEvent evt, CancellationToken ct = default)
    {
        logger.LogInformation("[NullPublisher] flow-cancel-requested skipped: {Id}", evt.FlowInstanceId);
        return Task.CompletedTask;
    }
}
  • Step 4: Run → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~NullFlowCancelRequestedPublisherTest"

Expected: PASS

  • Step 5: Register DIDependencyInjection.cs: ใต้ทั้ง 2 ASB branch (line ~476/~492) เพิ่ม:
                services.AddSingleton<IFlowCancelRequestedPublisher, Messaging.AsbFlowCancelRequestedPublisher>();

และใน else branch (line ~502) เพิ่ม:

                services.AddSingleton<IFlowCancelRequestedPublisher, Messaging.NullFlowCancelRequestedPublisher>();
  • Step 6: Commit
git add src/UserService04.Domain/Ports/Onboarding/IFlowCancelRequestedPublisher.cs src/UserService02.Infrastructure/Messaging/AsbFlowCancelRequestedPublisher.cs src/UserService02.Infrastructure/DependencyInjection.cs tests/UserService05.Tests/Infrastructure/Messaging/NullFlowCancelRequestedPublisherTest.cs
git commit -m "feat(lego): add flow-cancel-requested publisher (port + ASB impl + DI)"

Task 9 — wire CancelAsync ให้ publish (post-submit เท่านั้น) [L8]

Files:

  • Modify: .../UserService03.Application/.../Commands/TransitionFlowInstance/TransitionFlowInstanceCommand.cs (constructor inject + CancelAsync ~
    )
  • Test: .../tests/UserService05.Tests/Application/Features/Onboarding/FlowTransitionHandlerTest.cs

CancelAsync มาถึงได้เฉพาะหลังผ่าน validator (post-submit set) → ทุก cancel ที่ handler นี้ทำ = post-submit → publish เสมอ. (Draft owner-cancel ไป path discard คนละ command — ดู Task 10 note.)

  • Step 1: Write failing test — เพิ่ม mock + assertion ใน FlowTransitionHandlerTest.

(1a) เพิ่มใน Build(...) — สร้าง mock แล้วส่งเข้า constructor (เพิ่ม out param):

        var cancelPub = new Mock<IFlowCancelRequestedPublisher>();
        cancelPub.Setup(p => p.PublishCancelRequestedAsync(It.IsAny<FlowCancelRequestedEvent>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);

ส่ง cancelPub.Object เป็น argument สุดท้ายของ new TransitionFlowInstanceHandler(...) และ return cancelPub ใน tuple (ปรับ signature ของ Build + caller ที่ใช้ — เพิ่ม field ใน tuple Mock<IFlowCancelRequestedPublisher> CancelPub).

(1b) test ใหม่:

    [Fact]
    public async Task Cancel_post_submit_publishes_cancel_requested()
    {
        var fi = Instance(FlowInstanceStatus.Submitted, Snapshot(Step("A")));
        var (handler, _, _, _, _, cancelPub) = Build(fi);
        await handler.Handle(new TransitionFlowInstanceCommand(Guid.NewGuid(), fi.Id, TransitionAction.Cancel, Reason: "x"), default);
        cancelPub.Verify(p => p.PublishCancelRequestedAsync(
            It.Is<FlowCancelRequestedEvent>(e => e.FlowInstanceId == fi.Id && e.InitiatedBy == CancelInitiatedBy.Lego && e.Mode == CancelMode.Manual),
            It.IsAny<CancellationToken>()), Times.Once);
    }
  • Step 2: Run → FAIL (constructor ยังไม่รับ publisher → compile error; หลังแก้ test helper จะ fail ที่ Verify)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~Cancel_post_submit_publishes_cancel_requested"

Expected: FAIL

  • Step 3: ImplementTransitionFlowInstanceCommand.cs:

(3a) เพิ่ม IFlowCancelRequestedPublisher cancelPublisher, ใน primary constructor ของ TransitionFlowInstanceHandler (เช่นต่อจาก IFlowStatusNotificationPublisher notificationPublisher,).

(3b) แก้ CancelAsync (ก่อน return Result<bool>.Success(true);):

        await AuditTransitionAsync(instance, "FLOWINSTANCE_CANCELLED", cmd.DecisionId, ct, cmd.Reason);

        // [Orchestration] post-submit cancel → publish ให้ orchestrator fan-out teardown Task/Workflow (00 §6).
        // CancelAsync มาถึงได้เฉพาะ post-submit (validator gate) → publish เสมอ; mode=Manual (admin/owner-explicit)
        await cancelPublisher.PublishCancelRequestedAsync(new FlowCancelRequestedEvent(
            Guid.NewGuid(), DateTime.UtcNow, instance.Id, instance.RefNo,
            CancelInitiatedBy.Lego, CancelMode.Manual, cmd.Reason), ct);

        return Result<bool>.Success(true);

(เพิ่ม using UserService04.Domain.Ports.Onboarding; ถ้ายังไม่มี — มีแล้วจาก existing notification port)

  • Step 4: Run → PASS (รวม regression cancel tests เดิม)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowTransitionHandlerTest"

Expected: PASS

  • Step 5: Commit
git add src/UserService03.Application/Features/Onboarding/Commands/TransitionFlowInstance/TransitionFlowInstanceCommand.cs tests/UserService05.Tests/Application/Features/Onboarding/FlowTransitionHandlerTest.cs
git commit -m "feat(lego): publish flow-cancel-requested on post-submit admin cancel"

Task 10 — Expiry sweep: post-submit-stale → Cancel + publish [L9]

⚠️ code จริง FlowInstanceOrphanCleanupService ปัจจุบันแตะเฉพาะ Draft/Rework (line 53-55) — post-submit ปล่อยไว้. งานนี้ = เพิ่ม branch post-submit-stale ที่ Cancel(mode=Expiry)+publish. Draft/Rework เดิมยัง Abandon (local, ไม่ publish). ⚠️ Rework: เอกสาร L9 บอก Draft-stale→Abandon; แต่ code เดิมรวม Rework ใน Abandon path. ตัดสินใจ: คง Rework ใน Abandon path เดิม (Rework = owner ถือใบแก้, ไม่มี active Task/Workflow ฝั่ง saga ตอน ReworkRequested — saga teardown ตอน Decision(Rework) ไปแล้ว) → ไม่ publish. = แก้เฉพาะจุด ไม่ touch Rework behavior.

Files:

  • Modify: .../UserService02.Infrastructure/Onboarding/FlowInstanceOrphanCleanupService.cs
  • Test (ใหม่): .../tests/UserService05.Tests/Infrastructure/Onboarding/FlowOrphanSweepTest.cs

สำหรับ test: สกัด decision (status → action) เป็น static OrphanSweepPolicy.Resolve(status) เพื่อ test ได้โดยไม่ spin background service (mirror guard-extraction pattern จาก Task 7). Service เรียก policy นี้.

  • Step 1: Write failing test
using UserService02.Infrastructure.Onboarding;
using UserService04.Domain.Ports.Enums;
using Xunit;

namespace UserService05.Tests.Infrastructure.Onboarding;

public class FlowOrphanSweepTest
{
    [Theory]
    [InlineData(FlowInstanceStatus.Draft, OrphanSweepAction.Abandon)]
    [InlineData(FlowInstanceStatus.Rework, OrphanSweepAction.Abandon)]
    [InlineData(FlowInstanceStatus.Submitted, OrphanSweepAction.CancelAndPublish)]
    [InlineData(FlowInstanceStatus.InReview, OrphanSweepAction.CancelAndPublish)]
    [InlineData(FlowInstanceStatus.Reviewed, OrphanSweepAction.CancelAndPublish)]
    [InlineData(FlowInstanceStatus.Resubmitted, OrphanSweepAction.CancelAndPublish)]
    [InlineData(FlowInstanceStatus.Approved, OrphanSweepAction.None)]
    [InlineData(FlowInstanceStatus.Finalized, OrphanSweepAction.None)]
    public void Resolve_maps_status_to_sweep_action(FlowInstanceStatus s, OrphanSweepAction expected)
        => Assert.Equal(expected, OrphanSweepPolicy.Resolve(s));
}
  • Step 2: Run → FAIL (OrphanSweepPolicy ไม่มี)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowOrphanSweepTest"

Expected: build error

  • Step 3: Implement

(3a) เพิ่ม policy ในไฟล์ FlowInstanceOrphanCleanupService.cs (top of namespace):

/// <summary>sweep action ต่อ status (Draft/Rework=Abandon local; post-submit=Cancel+publish teardown)</summary>
public enum OrphanSweepAction { None, Abandon, CancelAndPublish }

public static class OrphanSweepPolicy
{
    public static OrphanSweepAction Resolve(FlowInstanceStatus status) => status switch
    {
        FlowInstanceStatus.Draft or FlowInstanceStatus.Rework => OrphanSweepAction.Abandon,
        FlowInstanceStatus.Submitted or FlowInstanceStatus.InReview
            or FlowInstanceStatus.Reviewed or FlowInstanceStatus.Resubmitted => OrphanSweepAction.CancelAndPublish,
        _ => OrphanSweepAction.None, // Approved/Rejected/Finalized/Cancelled/Abandoned/Closed = ไม่แตะ
    };
}

(3b) แก้ RunCleanupAsync — resolve teardown + cancel publisher จาก scope, แทน block if (instance is not null && Draft/Rework):

        var teardown = scope.ServiceProvider.GetRequiredService<IFlowInstanceTeardownService>();
        var cancelPublisher = scope.ServiceProvider.GetRequiredService<IFlowCancelRequestedPublisher>();

แล้วใน loop แทนที่ block เดิม (line ~53-63):

                var instance = await instanceRepo.GetByIdAsync(session.FlowInstanceId, ct);
                if (instance is not null)
                {
                    switch (OrphanSweepPolicy.Resolve(instance.Status))
                    {
                        case OrphanSweepAction.Abandon:
                            // Draft/Rework — owner ถือใบ, ไม่มี active saga → teardown orphan identity + local abandon (ไม่ publish)
                            await teardown.TeardownOwnerIdentityAsync(instance, ct);
                            instance.Abandon("orphan-cleanup");
                            await instanceRepo.UpdateAsync(instance, ct);
                            logger.LogInformation("[FlowOrphanCleanup] Abandoned (local) — Instance: {Instance}", instance.Id);
                            break;

                        case OrphanSweepAction.CancelAndPublish:
                            // post-submit — มี Task/Workflow ฝั่ง saga → Cancel + publish ให้ orchestrator fan-out teardown
                            await teardown.TeardownOwnerIdentityAsync(instance, ct);
                            // CancelledByActor มีแค่ Owner/Admin (verified) → sweep = Admin (system-initiated compensate)
                            instance.Cancel(CancelledByActor.Admin, "expiry-sweep", "orphan-cleanup");
                            await instanceRepo.UpdateAsync(instance, ct);
                            await cancelPublisher.PublishCancelRequestedAsync(new FlowCancelRequestedEvent(
                                Guid.NewGuid(), DateTime.UtcNow, instance.Id, instance.RefNo,
                                CancelInitiatedBy.Lego, CancelMode.Expiry, "expiry-sweep"), ct);
                            logger.LogInformation("[FlowOrphanCleanup] Cancelled (Expiry) + published — Instance: {Instance}", instance.Id);
                            break;
                    }
                }

                await editSessionRepo.DeactivateAsync(session.SessionId, ct);

(เพิ่ม using UserService04.Domain.Ports.Onboarding; — สำหรับ IFlowCancelRequestedPublisher + event)

⚠️ ตรวจ CancelledByActor มี System member; ถ้าไม่มี ให้ใช้ Admin (sweep = system-initiated, ฝั่ง audit). อ่าน CancelledByActor.cs ก่อน implement.

  • Step 4: Run → PASS
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowOrphanSweepTest"

Expected: PASS

  • Step 5: Commit
git add src/UserService02.Infrastructure/Onboarding/FlowInstanceOrphanCleanupService.cs tests/UserService05.Tests/Infrastructure/Onboarding/FlowOrphanSweepTest.cs
git commit -m "feat(lego): sweep post-submit-stale to Cancel+publish, Draft/Rework local Abandon"

Task 11 — Requestor notification ครบ: +PublishRejected/Cancelled [L10]

Files:

  • Modify: .../UserService04.Domain/Ports/Onboarding/IFlowStatusNotificationPublisher.cs

  • Modify: .../UserService02.Infrastructure/Messaging/AsbFlowStatusNotificationPublisher.cs (Asb + Null)

  • Modify: .../UserService03.Application/.../TransitionFlowInstanceCommand.cs (RejectAsync, CancelAsync, PublishStatusNotificationAsync)

  • Test: .../tests/UserService05.Tests/Application/Features/Onboarding/FlowTransitionHandlerTest.cs

  • Step 1: Write failing test — ใน FlowTransitionHandlerTest, ปรับ Build ให้ user repo คืน user จริง + notification mock capture, แล้ว test Reject/Cancel ยิง:

(1a) เพิ่ม overload param bool withUser = false ใน Build; ถ้า withUser ให้ userRepo คืน user (มี Email) และคืน Mock<IFlowStatusNotificationPublisher> ใน tuple แทน .Object ตรง:

        var notif = new Mock<IFlowStatusNotificationPublisher>();
        var user = withUser
            ? (UserService04.Domain.Entities.Users.User?)MakeUser(instance.OwnerUserId!.Value)
            : null;
        userRepo.Setup(u => u.GetUserByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);

(ส่ง notif.Object เข้า handler; return notif ใน tuple. MakeUser = helper สร้าง User ขั้นต่ำที่มี Email — อ่าน User entity เพื่อ factory ที่ถูกต้อง; ถ้า construct ยาก ใช้ stub object ที่ setup Email/Id ผ่าน reflection-free factory ที่มีใน entity.)

(1b) test:

    [Fact]
    public async Task Reject_publishes_requestor_rejected_notification()
    {
        var fi = Instance(FlowInstanceStatus.Submitted, Snapshot(Step("A")));
        var (handler, _, _, _, _, _, notif) = Build(fi, withUser: true);
        await handler.Handle(new TransitionFlowInstanceCommand(Guid.NewGuid(), fi.Id, TransitionAction.Reject, Reason: "ไม่ครบ"), default);
        notif.Verify(n => n.PublishRejectedAsync(It.IsAny<FlowStatusNotificationEvent>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Cancel_publishes_requestor_cancelled_notification()
    {
        var fi = Instance(FlowInstanceStatus.Submitted, Snapshot(Step("A")));
        var (handler, _, _, _, _, _, notif) = Build(fi, withUser: true);
        await handler.Handle(new TransitionFlowInstanceCommand(Guid.NewGuid(), fi.Id, TransitionAction.Cancel, Reason: "ยกเลิก"), default);
        notif.Verify(n => n.PublishCancelledAsync(It.IsAny<FlowStatusNotificationEvent>(), It.IsAny<CancellationToken>()), Times.Once);
    }
  • Step 2: Run → FAIL (PublishRejectedAsync ไม่มีใน interface)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~publishes_requestor"

Expected: build error does not contain a definition for 'PublishRejectedAsync'

  • Step 3a: Implement port — เพิ่มใน IFlowStatusNotificationPublisher:
    Task PublishRejectedAsync(FlowStatusNotificationEvent evt, CancellationToken ct = default);
    Task PublishCancelledAsync(FlowStatusNotificationEvent evt, CancellationToken ct = default);
  • Step 3b: Implement Asb + NullAsbFlowStatusNotificationPublisher.cs:

(Asb) เพิ่ม:

    public Task PublishRejectedAsync(FlowStatusNotificationEvent evt, CancellationToken ct = default)
        => Publish("flow-rejected", evt, ct);

    public Task PublishCancelledAsync(FlowStatusNotificationEvent evt, CancellationToken ct = default)
        => Publish("flow-cancelled", evt, ct);

(Null) เพิ่ม:

    public Task PublishRejectedAsync(FlowStatusNotificationEvent evt, CancellationToken ct = default)
    {
        logger.LogInformation("[NullPublisher] flow-rejected skipped: {Id}", evt.FlowInstanceId);
        return Task.CompletedTask;
    }

    public Task PublishCancelledAsync(FlowStatusNotificationEvent evt, CancellationToken ct = default)
    {
        logger.LogInformation("[NullPublisher] flow-cancelled skipped: {Id}", evt.FlowInstanceId);
        return Task.CompletedTask;
    }
  • Step 3c: Wire handlerTransitionFlowInstanceCommand.cs:

แก้ PublishStatusNotificationAsync เพิ่ม branch Reject/Cancel:

            if (instance.Status is FlowInstanceStatus.Approved or FlowInstanceStatus.Finalized)
                await notificationPublisher.PublishApprovedAsync(evt, ct);
            else if (instance.Status == FlowInstanceStatus.Rework)
                await notificationPublisher.PublishReworkAsync(evt, ct);
            else if (instance.Status == FlowInstanceStatus.Rejected)
                await notificationPublisher.PublishRejectedAsync(evt, ct);
            else if (instance.Status == FlowInstanceStatus.Cancelled)
                await notificationPublisher.PublishCancelledAsync(evt, ct);

ใน RejectAsync เพิ่มก่อน return (หลัง AuditTransitionAsync):

        await PublishStatusNotificationAsync(instance, correctionNote: null, ct);

ใน CancelAsync เพิ่มก่อน publish cancel-requested (หลัง audit):

        await PublishStatusNotificationAsync(instance, correctionNote: cmd.Reason, ct);
  • Step 4: Run → PASS (รวม regression notification tests เดิม)
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj --filter "FullyQualifiedName~FlowTransitionHandlerTest|FullyQualifiedName~FlowTransitionApproveSideEffects"

Expected: PASS

  • Step 5: Commit
git add src/UserService04.Domain/Ports/Onboarding/IFlowStatusNotificationPublisher.cs src/UserService02.Infrastructure/Messaging/AsbFlowStatusNotificationPublisher.cs src/UserService03.Application/Features/Onboarding/Commands/TransitionFlowInstance/TransitionFlowInstanceCommand.cs tests/UserService05.Tests/Application/Features/Onboarding/FlowTransitionHandlerTest.cs
git commit -m "feat(lego): requestor notification on Reject and Cancel (PublishRejected/Cancelled)"

Task 12 — Full regression gate + coverage

Files: none (verification only)

  • Step 1: Run full suite
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_UserService/tests/UserService05.Tests/UserService05.Tests.csproj

Expected: all PASS, onboarding ≥ 94 เดิม + tests ใหม่จาก T1-T11 (no regression)

  • Step 2: ตรวจ DI ครบ — verify IFlowCancelRequestedPublisher register ครบ 3 branch + FlowProgressUpdateConsumer register 2 ASB branch (compile + DI resolve ที่ startup; รัน API ขึ้น local ถ้าจำเป็น):
dotnet build C:/Source/AzureDevOps_SuperAPP/Backend_UserService/src/UserService01.API/UserService01.API.csproj

Expected: build success, 0 error

  • Step 3: Commit (ถ้ามี fix) — ถ้าไม่มี ข้าม.

Self-Review — Spec coverage (L1-L10)

TaskCoversสถานะ
L1 (+4 status)T1
L2 (ApplyProgress + resubmit landing + metadata)T2 (ApplyProgress+metadata), T3 (resubmit landing)✅ (metadata ผ่าน column ใหม่ — Assumption #1)
L3 (IsActiveSlot)T4
L4 (validator widen)T5
L5 (progress consumer)T7
L6 (ApprovalWireMapper +CANCEL)T6
L7 (cancel publisher)T8
L8 (wire CancelAsync publish)T9
L9 (expiry sweep split)T10
L10 (requestor notification ครบ)T11
(regression gate)T12

L11/L12 ตัดออกตาม grill — ไม่มีใน task list (ถูกต้อง).

จุดที่ยัง assumption / ต้องยืนยันก่อน execute:

  1. Assumption #1 (สำคัญสุด): progress metadata persist = column ใหม่ → migration 1 ตัว (ไม่ใช่ pure no-migration ตามตัวอักษรเอกสาร). ต้องยืนยัน user.
  2. CancelledByActor.System มีจริงไหม (T10 Step 3b) — อ่าน CancelledByActor.cs ก่อน; fallback = Admin.
  3. User entity factory สำหรับ test withUser (T11) — อ่าน User เพื่อสร้าง instance ที่มี Email/Id.
  4. Topic/subscription names (flow-progress-update, flow-cancel-requested) + payload field — ตรง contract แต่ต้อง lock กับ orchestrator (Phase-0).
  5. HistoryAction สำหรับ progress — T2 ใช้ Submit placeholder; ถ้าต้อง audit แยกค่อยเพิ่ม enum (YAGNI ตอนนี้).