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)
- 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 ตัว ทำบนMIGRATIONbranch ตาม [MIGRATION-branch workflow memory] แล้ว merge เข้า feature. status enum (L1) ยังคง no-migration จริง (string columnHasMaxLength(30)). → ก่อนเริ่ม Task 2 ต้องยืนยันกับ user ว่ารับ migration 1 ตัวนี้ หรือจะเก็บ metadata เป็น in-memory-only (ไม่ persist ข้าม restart). แผนนี้เดินด้วยทางเลือก persist-via-new-column. InReviewมีที่มาจริงไหม = open item รอทีม Workflow (มี pickup/claim ไหม) (CONTEXT.md Open item). แผนนี้ implement ฝั่ง Lego ให้ รับ progress ได้ทั้งInReviewและReviewed; ถ้า Workflow ไม่มี pickup orchestrator แค่ไม่เคยส่งInReview— code ฝั่ง Lego ไม่ต้องแก้.- Topic/subscription names ของ
flow-progress-update+flow-cancel-requestedใช้ค่าตาม contract matrix (00 §7.1). subscription ฝั่ง Lego ตั้งชื่อตาม pattern เดิมuserservice-<topic>. flow-progress-updatepayloadprogressStatusเป็น string"InReview"/"Reviewed"(ตรง enum name) ตาม 00 §7.2.
File Structure
| ไฟล์ | รับผิดชอบ | Task |
|---|---|---|
UserService04.Domain/Ports/Enums/FlowInstanceStatus.cs | +4 enum value | T1 |
UserService04.Domain/Entities/Onboarding/FlowInstance.cs | ApplyProgress, resubmit landing, IsActiveSlot, progress metadata accessor | T2,T3,T4 |
UserService02.Infrastructure/Persistence/Configurations/Onboarding/FlowInstanceConfiguration.cs | map ProgressMetadataJson jsonb | T2 |
UserService03.Application/.../Engine/FlowTransitionValidator.cs | widen in-flight set | T5 |
UserService03.Application/.../Engine/ApprovalWireMapper.cs | +CANCEL case | T6 |
UserService02.Infrastructure/Messaging/FlowProgressUpdateConsumer.cs (ใหม่) | consume progress channel | T7 |
UserService04.Domain/Ports/Onboarding/IFlowCancelRequestedPublisher.cs (ใหม่) | port + event payload | T8 |
UserService02.Infrastructure/Messaging/AsbFlowCancelRequestedPublisher.cs (ใหม่) | ASB publisher + Null fallback | T8 |
UserService03.Application/.../Commands/TransitionFlowInstance/TransitionFlowInstanceCommand.cs | CancelAsync publish + Reject/Cancel notification | T9,T11 |
UserService04.Domain/Ports/Onboarding/IFlowStatusNotificationPublisher.cs | +PublishRejected/Cancelled | T11 |
UserService02.Infrastructure/Messaging/AsbFlowStatusNotificationPublisher.cs | impl 2 method ใหม่ + Null | T11 |
UserService02.Infrastructure/Onboarding/FlowInstanceOrphanCleanupService.cs | post-submit sweep → Cancel+publish | T10 |
UserService02.Infrastructure/DependencyInjection.cs | register publisher + consumer | T8,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:
InReviewnot 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/AssignedMakerUsernamenot 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) — บน
MIGRATIONbranch (ไม่ใช่ 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 รับเฉพาะ
Submitted→InReviewคืน 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→ handlerCancelAsync(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) แยกเป็น staticFlowProgressGuardเพื่อ 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 ในไฟล์เดียว, mirrorFlowApprovalResultConsumerstructure):
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 port —
IFlowCancelRequestedPublisher.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 publisher —
AsbFlowCancelRequestedPublisher.cs(mirrorAsbFlowApprovalEventPublisher):
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 DI —
DependencyInjection.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: Implement —
TransitionFlowInstanceCommand.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มีSystemmember; ถ้าไม่มี ให้ใช้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 + Null —
AsbFlowStatusNotificationPublisher.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 handler —
TransitionFlowInstanceCommand.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
IFlowCancelRequestedPublisherregister ครบ 3 branch +FlowProgressUpdateConsumerregister 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)
| Task | Covers | สถานะ |
|---|---|---|
| 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:
- Assumption #1 (สำคัญสุด): progress metadata persist = column ใหม่ → migration 1 ตัว (ไม่ใช่ pure no-migration ตามตัวอักษรเอกสาร). ต้องยืนยัน user.
CancelledByActor.Systemมีจริงไหม (T10 Step 3b) — อ่านCancelledByActor.csก่อน; fallback =Admin.Userentity factory สำหรับ testwithUser(T11) — อ่านUserเพื่อสร้าง instance ที่มีEmail/Id.- Topic/subscription names (
flow-progress-update,flow-cancel-requested) + payload field — ตรง contract แต่ต้อง lock กับ orchestrator (Phase-0). HistoryActionสำหรับ progress — T2 ใช้Submitplaceholder; ถ้าต้อง audit แยกค่อยเพิ่ม enum (YAGNI ตอนนี้).