Company Notification — Implementation Plan
แผนลงมือแบบ task-by-task ที่ agent execute ได้ พร้อม acceptance criteria และ verification command ต่อ task, STOP gate ที่ห้ามข้าม, migration บน branch MIGRATION และ deploy ordering
อัปเดต: 2026-07-30
Company Notification — Implementation Plan (task-by-task)
Design source เดียว: COMPANY_NOTIFICATION_SPEC_V2.md — §ทุกอันในเอกสารนี้อ้างถึงไฟล์นั้น ห้ามอ่านเอกสารอื่นในโฟลเดอร์นี้ประกอบ
Repo ที่แตะ
| repo | absolute path | branch ตั้งต้น |
|---|---|---|
| Backend_Package | C:\Source\AzureDevOps_SuperAPP\Backend_Package | development |
| Backend_UserService | C:\Source\AzureDevOps_SuperAPP\Backend_UserService | development |
| Backend_NotificationService | C:\Source\AzureDevOps_SuperAPP\Backend_NotificationService | development (+ MIGRATION มีอยู่แล้ว) |
| Frontend_HostAppSuperApp | C:\Source\AzureDevOps_SuperAPP\Frontend_HostAppSuperApp | development |
| Backend_Iac | C:\Source\AzureDevOps_SuperAPP\Backend_Iac | development |
0. กฎเหล็ก — อ่านก่อนหยิบ task ใดก็ตาม
- 🛑 STOP gate ยังไม่มีคำตอบ = หยุดและรายงาน ห้ามแก้ด้วยการ “เลือกตัวเลือกที่ compile ผ่าน” / “เลือกอันที่ดูสมเหตุสมผล” / “ทำไปก่อนแล้วค่อยแก้” ถ้า task บอก
blocked-by: S-xและ S-x ยังว่าง → รายงานกลับว่าติด gate ไหน แล้วหยุด ไม่ต้องหยิบ task ถัดไปที่ blocked เหมือนกัน - Migration ต้อง author บน branch
MIGRATIONเท่านั้น — มี migration task เดียวในแผนนี้ (T-07) ห้ามรันdotnet ef migrations addบน feature branch ห้าม cherry-pick migration+snapshot ข้าม branch - ห้ามลอก
test-push/test-broadcast(NotificationsController.cs:88-110) เป็นแม่แบบของ write path ใดๆ — มันมีแค่[Authorize]ระดับ class ดูรายละเอียดที่ §1 S-1 - ทุก task ต้องจบด้วย verification ที่รันจริง ห้าม claim ผ่านโดยไม่มี output — “น่าจะผ่าน” ไม่นับ
- ไม่แตะโค้ดนอก
filesของ task — ไม่ refactor เพื่อนบ้าน ไม่จัด format ไฟล์เดิม - FE: ห้ามละเมิด 2 ข้อนี้ทุกกรณี (SPEC_V2 §9.1) —
subscribe('Notification', ...)ต้องมี subscriber เดียวทั้งแอป (ตัวที่มีอยู่คือrealtime-app-connection.service.ts:72) และ ห้ามเรียกdestroyCachedClients()
1. สถานะ gate
1.1 ✅ ตัดสินแล้ว (ใช้ได้เลย)
| ID | สรุป |
|---|---|
| D-1 | scope กระดิ่ง = ทุกบริษัทที่ Status == "Active" ไม่ใช่ active-company-only ⇒ ไม่มี reconnect flow ตอนสลับบริษัท ไม่แตะ corporate-panel.state.ts |
| D-2 | เห็นย้อนหลัง ตั้งแต่ joinedAt ของบริษัทนั้น · joinedAt == null (blob เก่า) ⇒ fail-open เห็นทั้งหมด |
1.2 ✅ STOP gate เดิม — ตอบครบแล้ว (Owner, 30/07/2026)
| ID | คำตอบ | ปลดล็อกอะไร |
|---|---|---|
| D-3 (เดิม S-1) | ASB event จาก service ภายในเท่านั้น — ไม่มี HTTP endpoint สำหรับสร้างประกาศบริษัท | T-23, T-24 · ปิดช่องโหว่ที่ร้ายแรงที่สุดของแผนเดิมไปเลย: ไม่มี public surface ⇒ ไม่มีเคส “user ที่ login คนไหนก็ยิงประกาศเข้าทั้งบริษัท” และไม่ต้องออกแบบกฎ caller→companyId |
| D-4 (เดิม S-2) | receipt อย่างเดียว ไม่เพิ่มตาราง ไม่เพิ่ม retention job — พึ่ง D-2 คุม | T-07 |
| D-5 (เดิม S-3) | AppId Guid? และ กรองที่ server เทียบ UserAppInfo.AppId ใน blob | T-07, T-12, T-19 · ปิด H-04 — FE ไม่ต้องกรองอะไรเลย |
1.3 🔶 เหลือประสาน (ไม่ใช่ design decision — ไม่บล็อกการเขียนโค้ด)
| ID | ต้องได้อะไร | บล็อกอะไร |
|---|---|---|
| X-1 | ชื่อ ASB topic + service ที่ publish + message contract ของประกาศบริษัท | เขียน consumer ได้เลย (T-24) แต่ deploy จริงและ E2E (T-23) ยังไม่ได้ จนกว่าจะตกลงกับทีมเจ้าของ event |
X-1 ต่างจาก STOP gate เดิม: เดิม S-1 เป็นคำถามว่า ระบบควรมีหน้าตาแบบไหน (design + security) ตอนนี้เหลือแค่ ชื่อและ schema ที่ต้องตกลงกัน (coordination) ⇒ เขียนโค้ดคู่ขนานไปได้ แต่ห้าม เดา ชื่อ topic แล้ว deploy
1.4 ⚠️ trade-off ที่ Owner รับไว้แล้ว — เขียนไว้เพื่อไม่ให้ใครไป “แก้” เอง
- D-4:
mark-allยังINSERT N rowต่อคลิก และ receipt โตO(users × notifications)ไม่มีเพดานระยะยาว ⇒ ต้องเฝ้าขนาดตาราง ถ้าโตเกินคาดค่อยเติม retention ทีหลัง (เติมได้โดยไม่แตะ schema เพราะ FK cascade ลบ receipt ให้เอง) — ห้ามแอบใส่ watermark table เองเพราะเห็นว่า “ดีกว่า” Owner ปฏิเสธไปแล้ว
2. Phase map + task index
Phase order ตาม SPEC_V2 §12 (P-1 → UserService → migration → NS code → FE → trigger)
| # | task | phase | blocked-by | human review |
|---|---|---|---|---|
| T-01 | Backend_Package: UserCompanyInfo.JoinedAt | 1 | — | |
| T-02 | publish SupApp_util_lib + bump 2 consumer (cross-repo gate) | 1 | T-01 | |
| T-03 | P-3 ปิดช่อง re-warm ทับ invalidation | 1 | — | ✅ (b) |
| T-04 | P-4 probe: GetUserInfo() ใน OnConnectedAsync | 1 | — | |
| T-05 | UserService: map JoinedAt | 2 | T-02 | |
| T-06 | P-2 membership-change invalidation | 2 | T-02 | ✅ (b) |
| T-07 | migration เดียวของแผนนี้ | 3 | ✅ ปลดล็อกแล้ว (D-4 + D-5) | ✅ (a) |
| T-08 | §5 authorization guard ResolveMemberCompanies | 4 | T-02 | ✅ (b) |
| T-09 | repository + port + DbSet + DI | 4 | T-07 | |
| T-10 | write path SendCompanyAsync + NotifyCompanyAsync | 4 | T-09 | ✅ (c) |
| T-11 | combined unread count (§7.1 / §7.3) | 4 | T-08, T-09 | |
| T-12 | union list query + validator whitelist (§7.2) | 4 | T-08, T-09 | |
| T-13 | mark-read routing ด้วย Scope (§7.4) | 4 | T-08, T-09 | ✅ (b) |
| T-14 | mark-all read (§7.5) | 4 | T-08, T-09 | |
| T-15 | detail by id + 404 negative test (§7.6) | 4 | T-08, T-09 | ✅ (b) |
| T-16 | AppHub join company group (§8.1) | 4 | T-04, T-08, T-11 | ✅ (b) |
| T-17 | ActionLink allowlist + config key (§10.4) | 4 | T-10 | ✅ (c) |
| T-18 | IaC parity ของ config key ใหม่ (cross-repo) | 4 | T-17 | |
| T-19 | FE model/mapper เก็บ scope (§9.2) | 5 | — | |
| T-20 | FE markAsRead ส่ง scope (§9.2) | 5 | T-13, T-19 | |
| T-21 | FE thundering herd (§9.3) + constraint guard (§9.1) | 5 | T-19 | |
| T-22 | FE UI รองรับ scope === 'Company' (§9.4) | 5 | T-19 | |
| T-23 | E2E headed (§13 FE) | 6 | X-1 + T-22, T-24 | |
| T-24 | ASB consumer (CompanyNotificationConsumer) | 6 | T-10 · deploy รอ X-1 | ✅ (c) |
ทุก task เขียนโค้ดได้แล้ว — เหลือแค่ T-23 (E2E) ที่ต้องรอ X-1 และ T-24 ที่เขียนได้แต่ deploy รอ X-1
🔒 ทางเข้าเดียวคือ ASB (D-3) — ห้ามสร้าง HTTP endpoint สำหรับประกาศบริษัทเด็ดขาด ไม่ว่าจะเพื่อทดสอบ เพื่อ demo หรือ “ชั่วคราว” · ถ้ารู้สึกว่าจำเป็นต้องมี endpoint เพื่อทดสอบ = หยุดแล้วรายงาน อย่าสร้าง
3. Phase 1 — Backend_Package + probe
T-01 — เพิ่ม JoinedAt ใน UserCompanyInfo
| blocked-by | — |
| files | C:\Source\AzureDevOps_SuperAPP\Backend_Package\src\Middleware\RedisUserInfo\UserInfoForRedis.cs |
ทำอะไร: เพิ่ม property ใหม่ต่อจาก CustCode (บรรทัด 134-135) ใน record UserCompanyInfo โดยลอก pattern additive ของ CustCode มาทั้งดุ้น (comment 2 บรรทัดเดิมด้วย):
[JsonPropertyName("joinedAt")]
public DateTime? JoinedAt { get; init; }
ห้าม: bump CurrentSchemaVersion (บรรทัด 16) และห้ามแก้ default ของ SchemaVersion (บรรทัด 109) — guard เป็น strict != ⇒ bump แล้ว reader ที่ยังไม่อัปจะทิ้ง blob ทั้งก้อน
Acceptance criteria
-
UserCompanyInfoมี propertyJoinedAttypeDateTime?+[JsonPropertyName("joinedAt")] -
CurrentSchemaVersionยังเป็น1 -
git diff --stat= 1 ไฟล์, เพิ่ม ≤ 6 บรรทัด, ลบ 0 บรรทัด
Verify
cd C:\Source\AzureDevOps_SuperAPP\Backend_Package
rg -n 'CurrentSchemaVersion = 1' src\Middleware\RedisUserInfo\UserInfoForRedis.cs # ต้องยังเจอ
git diff --stat
dotnet build
T-02 — publish SupApp_util_lib + bump consumer 2 repo
| blocked-by | T-01 |
| files | Backend_Package csproj (version) · Backend_UserService\src\**\*.csproj (PackageReference) · Backend_NotificationService\src\**\*.csproj (PackageReference) |
นี่คือ cross-repo release gate ไม่ใช่ code edit — task หลังจากนี้ที่ต้องอ่าน
JoinedAt(T-05, T-08) รอ task นี้ ไม่ใช่รอ T-01
ทำอะไร
- bump version ของ
SupApp_util_lib dotnet pack→ แตก.nupkgออกมาตรวจว่า DLL ข้างในมี symbolJoinedAtจริงก่อน push (dotnet packเคย packbinเก่า และ feed เป็น immutable — push version เสียแล้วแก้ไม่ได้ ต้อง bump ใหม่)export FEED_TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)แล้ว push (ห้ามใช้ PAT); ถ้า restore ได้ version เก่า →dotnet nuget locals http-cache --clear- bump
PackageReferenceในทั้ง UserService และ NotificationService (ทั้งคู่อ่าน blob)
Acceptance criteria
- symbol
JoinedAtปรากฏใน DLL ที่อยู่ใน.nupkgที่จะ push (ยืนยันก่อน push) - feed แสดง version ใหม่
- ทั้ง 2 consumer repo restore version ใหม่แล้ว build ผ่าน
Verify
cd C:\Source\AzureDevOps_SuperAPP\Backend_UserService; dotnet build src\UserService01.API\UserService01.API.csproj
cd C:\Source\AzureDevOps_SuperAPP\Backend_NotificationService; dotnet build src\Notification01.API\Notification01.API.csproj
T-03 — P-3: ปิดช่อง re-warm ทับ invalidation
| blocked-by | — (ทำขนานกับ T-01 ได้) |
| files | C:\Source\AzureDevOps_SuperAPP\Backend_Package\src\Middleware\RedisUserInfo\RedisUserInfoMiddleware.cs (ราว บรรทัด 173) |
| human review | ✅ (b) — บังคับ review ก่อน merge |
ทำอะไร: เปิดไฟล์ อ่านจุดที่ประทับ TTL ใหม่ทุกครั้งที่ HTTP fallback สำเร็จ ยืนยันพฤติกรรมด้วยการอ่านโค้ดจริงก่อน แล้วทำให้ re-warm ไม่ลบล้าง invalidation ที่เพิ่งเกิด (SPEC_V2 P-3)
⚠️ ไฟล์นี้อยู่ใน lib กลางที่ ~12 service ใช้ร่วมกัน และเป็น authz boundary ของ feature นี้ (SPEC_V2 §5) ⇒ ห้าม merge ก่อนผ่าน human review ถ้าการแก้ที่เล็กที่สุดยังกระทบ semantics ของ service อื่น → หยุด รายงาน trade-off ให้ Owner เลือก
Acceptance criteria
- มี unit test: invalidate แล้ว request ถัดไปที่ fallback สำเร็จ ไม่ ทำให้ค่าเก่ากลับมามีอายุยาวกว่าที่ควร
- ไม่มี service อื่นต้องแก้โค้ดตาม (ถ้าต้อง = หยุด รายงาน)
- diff อยู่ใน middleware ไฟล์เดียว
Verify: dotnet test ของ Backend_Package + แนบ diff ให้ reviewer
T-04 — P-4 probe: GetUserInfo() ใน OnConnectedAsync null หรือไม่
| blocked-by | — |
| files | C:\Source\AzureDevOps_SuperAPP\Backend_NotificationService\src\Notification01.API\Hubs\AppHub.cs (instrumentation ชั่วคราว — ไม่ merge) |
Deliverable ของ task นี้คือ “การตัดสินใจ + หลักฐาน” ไม่ใช่โค้ด — เลือกระหว่าง SPEC_V2 §8.2 ทาง A (GetHttpContext()?.GetUserInfo()) กับ ทาง B (อ่าน Redis ตรงด้วย oid จาก Context.User)
ข้อเท็จจริงที่ verify แล้ว ใช้ตั้งสมมติฐาน
MiddlewarePipelineExtensions.cs:109app.UseRedisUserInfo()มาก่อน:135app.MapHub<Notification.Hubs.AppHub>("/api/notification-service/hubs/app")⇒ middleware run จริงก่อนถึง hub- transport pin เป็น WebSockets +
skipNegotiation⇒ ไม่ต้องทดสอบ long-polling/SSE เหลือแค่ warm blob × cold blob - FE bootstrap
initialize()(connect) ก่อนsyncUserInfoAndPerm(...)⇒ เคส first-login / cold blob มีโอกาส null สูง — ต้องทดสอบเคสนี้โดยเฉพาะ
ทำอะไร: ใส่ log ชั่วคราวใน OnConnectedAsync (log แค่ null/not-null + จำนวน company — ห้าม log PII) deploy dev → เก็บหลักฐาน 2 เคส: (1) user ที่เพิ่ง login เสร็จ blob warm (2) first login / blob หมดอายุ
Acceptance criteria
- มี log จริงจาก dev ทั้ง 2 เคส
- บันทึกผลเป็นข้อความ: “เลือกทาง A/B เพราะ …”
- instrumentation ถูก revert ไม่ติดไปกับ commit ใดๆ
4. Phase 2 — Backend_UserService
T-05 — map JoinedAt = ucm.CreatedAt
| blocked-by | T-02 |
| files | C:\Source\AzureDevOps_SuperAPP\Backend_UserService\src\UserService03.Application\Features\Users\UsersMapper.cs (บล็อก companies บรรทัด 238-248) |
ทำอะไร: เพิ่ม JoinedAt = ucm.CreatedAt, ต่อจาก CustCode = ucm.Company?.CustCode ใน initializer ของ UserCompanyInfo — ไม่แตะบรรทัดอื่น
✅ ตรวจสอบแล้วตอนวางแผน (ไม่ต้องไปหยุดเอง): บรรทัด 245 คือ Status = ucm.IsStatus.ToString() ส่วน guard §5 (T-08) filter c.Status == "Active" — เปิด UserService04.Domain/Ports/Enums/UserCompanyStatus.cs:6-13 แล้ว member คือ Active = 1 ⇒ ToString() ได้ "Active" เป๊ะ contract ตรงกัน ใช้ได้
แต่ยังต้องมี AC เป็น regression guard เพราะถ้าใครเปลี่ยนชื่อ enum member ทีหลัง ทั้ง feature จะตายเงียบ (guard คืน [] → user ไม่เห็นประกาศบริษัทเลย โดยไม่มี error): ถ้าเจอว่าค่าที่ stringify ออกมาไม่ใช่ "Active" → หยุดและรายงาน ห้ามแอบแก้ค่าเทียบใน guard เอง เพราะเป็นการเปลี่ยนความหมายของ contract ข้าม service
Acceptance criteria
- initializer มี
JoinedAt = ucm.CreatedAt - regression assert:
UserCompanyStatus.Active.ToString() == "Active" - blob ของ user ทดสอบบน dev มี
companies[].joinedAtไม่เป็น null
Verify
cd C:\Source\AzureDevOps_SuperAPP\Backend_UserService; dotnet build
แล้วอ่าน blob จริง: GET {KeyPrefix}:user:info:{oid} แล้ว assert field joinedAt (Redis key prefix ต่อ env ตาม RedisUserInfoOptions)
T-06 — P-2 membership-change invalidation
| blocked-by | T-02 |
| files | Backend_UserService — ทุกจุดที่ mutate UserCompanyMapping (add / remove / เปลี่ยน status) — ต้อง enumerate เองก่อน ห้ามเดารายชื่อไฟล์ |
| human review | ✅ (b) |
ทำอะไร
rgหา write site ทั้งหมดของUserCompanyMapping(add / remove / soft-delete / เปลี่ยนIsStatus) แล้ว ทำลิสต์ให้ครบก่อนแก้- ทุก site ต้อง invalidate Redis blob key ของ user ที่กระทบ
- เขียนไว้ใน PR description ว่าลิสต์มีกี่จุด — reviewer ตรวจว่าครบ
ทำไมบังคับ: ไม่มี = คนที่ถูกถอดออกจากบริษัทยังอ่านประกาศบริษัทนั้นได้จน blob หมด TTL (authz hole) และคนเข้าใหม่ไม่เห็นอะไรเลยจนกว่า blob จะหมุน (feature พัง)
Acceptance criteria
- ลิสต์ write site ครบ แนบใน PR
- ทุก site invalidate
- integration/unit test: ถอด user ออกจากบริษัท → อ่าน blob ใหม่ได้
companiesที่ไม่มีบริษัทนั้น ภายใน request ถัดไป ไม่ใช่รอ TTL - revocation lag ที่เหลือถูกเขียนเป็นข้อความใน PR (SPEC_V2 §5 กฎ 5 บังคับให้ประกาศ)
Verify: dotnet test ของ UserService + หลักฐาน blob ก่อน/หลังถอดสิทธิ์
5. Phase 3 — Migration (task เดียวของทั้งแผน)
T-07 — AddCompanyNotifications
| blocked-by | ✅ ปลดล็อกแล้ว — D-4 (receipt อย่างเดียว) + D-5 (AppId Guid?) |
| human review | ✅ (a) — review schema + index shape ก่อน รัน migrations add |
คอลัมน์ app =
AppId uuid NULL(D-5) ห้ามใช้AppCode stringและ ไม่มีตาราง cursor/watermark (D-4) ห้ามเพิ่มเอง
ไฟล์ที่แตะ (source ที่ port ขึ้น MIGRATION ได้ = entity + EF config + DbSet เท่านั้น)
| ไฟล์ | สถานะ |
|---|---|
...\src\Notification04.Domain\Entities\Notification\CompanyNotification.cs | ใหม่ |
...\src\Notification04.Domain\Entities\Notification\CompanyNotificationRead.cs | ใหม่ |
...\src\Notification02.Infrastructure\Persistence\Configurations\Notification\CompanyNotificationConfiguration.cs | ใหม่ |
...\src\Notification02.Infrastructure\Persistence\Configurations\Notification\CompanyNotificationReadConfiguration.cs | ใหม่ |
...\src\Notification02.Infrastructure\Persistence\Contexts\AuthDbContext.cs | แก้ (เพิ่ม 2 DbSet ต่อจากบรรทัด 41-44) |
...\src\Notification02.Infrastructure\Migrations\* | generate |
เนื้อหา schema: ลอกจาก SPEC_V2 §4.1-§4.4 ทั้งชุด (index 3 ตัว + FK cascade + unique partial index) ห้ามตัดทอน จุดที่พลาดบ่อย:
CompanyNotificationต้อง: Entity, IAuditableEntityและ ประกาศIdเอง (baseEntityไม่ defineId— ดูInAppNotification.cs:6-8เป็นตัวอย่างจริงในโค้ดนี้)- ห้ามตัด
IAuditableEntityแม้ entity จะไม่มี mutator —AuditableEntityInterceptorfilter ด้วยEntries<IAuditableEntity>()⇒ ตัด interface = ไม่มีใคร setCreatedByที่ประกาศ.IsRequired()= NOT NULL violation ตอน insert CompanyNotificationReadเป็น POCO composite key ไม่ deriveEntity- index
(CompanyId, CreatedAt DESC, Id DESC)ต้องมีIdเป็น tiebreaker — tie เกิดจริงเพราะAuditableEntityInterceptor.csใช้DateTime.UtcNowครั้งเดียวต่อSaveChangesแล้ว assign ให้ทุก entity
ลำดับบังคับ — ห้ามข้ามห้ามสลับ
cd C:\Source\AzureDevOps_SuperAPP\Backend_NotificationService
git checkout MIGRATION
# 1) port เฉพาะ data-tier source: entity + EF config + DbSet (ห้ามพา handler/controller/service/validator มาด้วย)
# 2) generate migration บน MIGRATION เท่านั้น
dotnet ef migrations add AddCompanyNotifications --project src\Notification02.Infrastructure --startup-project src\Notification01.API
# 3) verify
dotnet ef migrations has-pending-model-changes --project src\Notification02.Infrastructure --startup-project src\Notification01.API
# 4) merge MIGRATION กลับเข้า feature branch ของงานนี้ (feature ต้องพา migration ติดไปด้วย)
ห้าม: git merge development เข้า MIGRATION (ลาก business logic เข้ามา) · cherry-pick ไฟล์ migration + ModelSnapshot ข้าม branch (lineage เพี้ยน) · author migration บน feature branch
Acceptance criteria
- คอลัมน์ app เป็น
AppId uuid NULL(ไม่ใช่AppCode) และไม่มีตาราง cursor เพิ่ม - Reviewer approve schema แล้วก่อน
migrations add(checkpoint a) -
has-pending-model-changes= No changes ทั้งบน MIGRATION และหลัง merge กลับเข้า feature branch -
git show --statของ commit บน MIGRATION ไม่มีไฟล์ในFeatures/,Controllers/,Hubs/,*Handler.cs,*Validator.cs - migration ที่ generate มีครบ: 2 table เท่านั้น, index
IX_company_notifications_company_created, unique partialIX_company_notifications_external_message_dedup,IX_company_notification_reads_user_notification, FK cascade
Verify: คำสั่งข้อ 3 ข้างบน (รันซ้ำหลัง merge) + dotnet build src\Notification01.API\Notification01.API.csproj
6. Phase 4 — Backend_NotificationService (code)
T-08 — authorization guard ResolveMemberCompanies
| blocked-by | T-02 (ต้องมี JoinedAt ใน package) |
| files | ...\src\Notification03.Application\Common\Authorization\CompanyScope.cs (ใหม่ — folder Common\Authorization ยังไม่มี สร้างใหม่) |
| human review | ✅ (b) |
ทำอะไร: ตาม SPEC_V2 §5 เป๊ะ — record CompanyScope(Guid CompanyId, DateTime? JoinedAt) + static ResolveMemberCompanies(UserInfoForRedis? info) ที่ filter c.Status == "Active" และคืน [] เมื่อ resolve ไม่ได้
เขียนกฎ 6 ข้อของ §5 เป็น XML doc comment บน method เพื่อให้ reviewer ของ task ถัดๆ ไปมีที่อ้าง:
- company id derive จาก
HttpContext.GetUserInfo()เท่านั้น — ห้าม model-bind จาก query/body/header - command/query record ที่มี
UserId/company ids ต้อง construct ใน controller เอง ห้าม[FromBody] - repo method ห้ามรับ
companyIdที่ไม่ผ่าน guard นี้ - ไม่มีบริษัท ⇒ 0 row เป๊ะ short-circuit ใน C# — ห้ามเขียนเป็น SQL predicate แบบ
@x IS NULL OR ... - revocation lag = อายุ blob ที่เหลือ (พึ่ง T-03 + T-06)
- group membership set ตอน connect เท่านั้น ⇒ ดู “ไม่ครอบคลุม” §10
Acceptance criteria
- unit test:
Status = "Inactive"→ ไม่ติดมา - unit test:
info == null→[] - unit test:
Companiesว่าง →[] -
rg "IS NULL OR"ใน repository ที่เกี่ยวกับ company = 0 hit
Verify: dotnet test tests\Notification05.Tests\Notification05.Tests.csproj
T-09 — repository + port + DbSet + DI
| blocked-by | T-07 |
| files | ...\src\Notification04.Domain\Ports\Persistence\ICompanyNotificationRepository.cs (ใหม่) · ...\src\Notification02.Infrastructure\Persistence\Repositories\CompanyNotificationRepository.cs (ใหม่) · keyless read-model NotificationListRow + config (ใหม่) · AuthDbContext.cs · DependencyInjection.cs (บล็อก repositories บรรทัด 157-166) |
ทำอะไร
- interface ต้องอยู่
Notification04.Domain/Ports/Persistence/ไม่ใช่ Infrastructure - ประกาศ keyless read-model:
modelBuilder.Entity<NotificationListRow>().HasNoKey().ToView(null)— ใช้กับ_db.Set<NotificationListRow>().FromSqlInterpolated(...)ใน T-12 (FromSqlบนDbSet<InAppNotification>ใช้ไม่ได้ shape ไม่ตรง → runtimeInvalidOperationException) หมายเหตุ:AuthDbContext.OnModelCreatingใช้ApplyConfigurationsFromAssembly⇒ ทำเป็นIEntityTypeConfigurationได้ - register
services.AddScoped<ICompanyNotificationRepository, Persistence.Repositories.CompanyNotificationRepository>();ต่อจากบรรทัด 163 - ห้าม เพิ่ม Dapper (repo นี้ไม่มี raw SQL / ไม่มี Dapper ใน csproj; ถ้าใส่ต้องส่ง
GetDbTransaction()เองไม่งั้นหลุด transaction) - repo ห้ามเรียก
_db.SaveChangesAsync()เอง —InAppNotificationRepository.cs:20,26ทำแบบนั้นอยู่ = precedent ที่ผิด ห้ามทำซ้ำ (ยกเว้น write path §6.1 ที่ commit เองโดยเจตนา ดู T-10)
Acceptance criteria
-
rg "SaveChangesAsync" CompanyNotificationRepository.cs= พบเฉพาะใน method ของ write path ที่ T-10 ระบุ ไม่มีใน read method - app resolve DI ได้ (startup ไม่ throw)
- interface อยู่ใต้
Notification04.Domain\Ports\Persistence\
Verify: dotnet build src\Notification01.API\Notification01.API.csproj + รัน service ขึ้นมา 1 ครั้ง ดูว่าไม่มี DI exception
T-10 — write path: SendCompanyAsync + NotifyCompanyAsync
| blocked-by | T-09 |
| files | ...\src\Notification04.Domain\Ports\Realtime\IRealTimeNotifier.cs · ...\src\Notification02.Infrastructure\Realtime\SignalRNotifier.cs · ...\src\Notification04.Domain\Ports\Notification\IInAppNotificationService.cs · ...\src\Notification02.Infrastructure\Notification\InAppNotificationService.cs |
| human review | ✅ (c) |
ขอบเขต — อ่านให้ชัด
| อยู่ใน task นี้ | ไม่อยู่ (เป็นของ T-24) |
|---|---|
NotifyCompanyAsync บน port + impl | endpoint HTTP ใดๆ |
SendCompanyAsync บน service | การ subscribe ASB topic ใดๆ |
InsertIfNotDuplicateAsync upsert ใน repo | 🔒 HTTP endpoint — D-3 ห้ามมีเด็ดขาด |
ทำอะไร
- เพิ่ม
NotifyCompanyAsync(string companyId, string eventName, object payload, CancellationToken ct = default)เข้าIRealTimeNotifier(ปัจจุบันมี 4 method:NotifyUserAsync/BroadcastAsync/ToggleMaintenanceAsync/GetMaintenanceStateAsync) แล้ว implement ในSignalRNotifier<THub>push เข้าClients.Group($"company-{companyId}") - ต้องมี metric ตอน push fail — ตัวที่มีอยู่ (
SignalRNotifier.cs:26,32)LogWarningแล้วกลืน exception เงียบ ไม่มี counter; ของใหม่ห้ามกลืนเงียบ SendCompanyAsyncตาม §6: upsert ก่อน (ON CONFLICT ("ExternalMessageId","CompanyId") DO NOTHING) →affected == 0⇒ duplicate ⇒ return ไม่ push → push หลัง commit- push payload ไม่มี
unreadCount(group push ใส่เลขรายคนไม่ได้)
ห้าม
- ❌ check-then-insert แบบ
AnyByExternalMessageIdAsync(pattern ที่InAppNotificationService.cs:33-39,74-81ใช้อยู่) — ASB at-least-once +FlowStatusNotificationConsumerabandon ตอน transient ⇒ redelivery ปกติ; 2 consumer เห็นEXISTS=false พร้อมกันได้ (READ COMMITTED) - ❌
catch (PostgresException ... 23505)— ถ้ามี ambient transaction จากTransactionBehaviortxn abort ไปแล้วตอน catch และEnableRetryOnFailureถูกปิดโดยเจตนา (DependencyInjection.cs:64-67) ไม่มี retry มารองรับ - ❌ dedup key ที่ไม่มี
CompanyId—WorkflowMakerDispatcher.csยิงวน recipients ด้วยexternalMessageIdตัวเดียว ⇒ key global = ประกาศเดียวยิงหลายบริษัทแล้วบริษัทที่ 2..N หายเงียบ - ❌ ให้ write path นี้วิ่งใต้
TransactionBehavior(§6.1) — จะ push ก่อน commit ⇒ rollback แล้ว client เห็นประกาศที่ไม่มีใน DB - ❌ log payload หรือ PII — log ได้แค่
CompanyId/NotificationId/SourceService
Acceptance criteria
- unit test: ยิง event เดียวไป 2 บริษัท → 2 row; ยิงซ้ำ → ยัง 2 row
- unit test: 2 call พร้อมกันด้วย
(externalMessageId, companyId)เดียวกัน → 1 row, ไม่มี 23505 หลุดออกมา, push ครั้งเดียว -
rg "PostgresException" src\Notification02.Infrastructure= 0 hit ในไฟล์ที่แก้ -
SourceServiceถูก derive จาก caller ไม่ได้มาจาก payload/body - มี counter เพิ่มขึ้นเมื่อ push fail
-
SendCompanyAsyncไม่ถูกเรียกจากICommandhandler ใดๆ (grep call site = 0 นอก test)
Verify: dotnet test tests\Notification05.Tests\Notification05.Tests.csproj — task นี้พิสูจน์ได้แค่ระดับ unit test (ทางเข้าจริงอยู่ที่ T-24) อย่ารายงานว่า “ทดสอบ end-to-end แล้ว”
T-11 — combined unread count
| blocked-by | T-08, T-09 |
| files | ...\Features\Notification\Queries\GetNotifications\GetNotificationsHandler.cs (บรรทัด 20) · ...\Features\Notification\Queries\GetUnreadCount\* · service method GetCombinedUnreadAsync |
ทำอะไร: GetNotificationsHandler.cs:20 ปัจจุบันเรียก repository.GetUnreadCountAsync(request.UserId, ...) (personal-only) → เปลี่ยนเป็น GetCombinedUnreadAsync(userId, companies, ct) ตาม §7.1 · SQL ใช้ NOT EXISTS correlated · companies.Count == 0 ⇒ return personal ทันที short-circuit ใน C#
⚠️ นี่คือ behavioral break ที่ FE เห็น —
UnreadCountเปลี่ยนความหมายจาก personal-only เป็น combined เขียนไว้ใน PR description
Acceptance criteria
- user ที่ไม่มีบริษัท → unread == personal เป๊ะ (assert ตัวเลข ไม่ใช่ assert ว่า “ไม่ error”)
- ประกาศที่
CreatedAt < joinedAtไม่ถูกนับ -
JoinedAt == null→ นับทั้งหมด (fail-open ตาม D-2) -
EXPLAIN (ANALYZE)ของ unread query: Index (Only) Scan + anti-join ไม่มี Seq Scan
Verify: dotnet test ... + รัน EXPLAIN (ANALYZE) บน dev DB (read-only) แนบ plan
T-12 — union list query + validator whitelist
| blocked-by | T-08, T-09 |
| files | CompanyNotificationRepository (union query) · keyless NotificationListRow · ...\Features\Notification\Queries\GetNotifications\GetNotificationsQueryValidator.cs |
ทำอะไร: SQL ตาม §7.2 ทั้งดุ้น + validator
จุดที่พังง่าย
- quote alias ทุกตัว
AS "Scope"ไม่ใช่AS scope— column ใน repo นี้เป็น PascalCase quoted จริง ไม่มี snake_case convention; unquoted จะ fold เป็น lowercase แล้ว EF map ไม่เจอ ORDER BY t."CreatedAt" DESC, t."Id" DESCบังคับ — ไม่มี tiebreaker = row ซ้ำ/หายข้ามหน้าTotalCountแยก 1 query ตาม pattern เดิม (InAppNotificationRepository.cs:47) —count(*) OVER ()หายเมื่อ OFFSET เกินท้าย- ทุกค่าเป็น parameter รวม
LIMIT/OFFSET - กรอง
AppIdที่ server (D-5) —AND (n."AppId" IS NULL OR n."AppId" = ANY(@myAppIds))ทั้ง list และ unread count ไม่งั้นเลขไม่ตรงกับ list ·@myAppIdsมาจากUserInfoForRedis.Apps[].AppIdซึ่งเป็นstring?⇒Guid.TryParseแล้วทิ้งตัวที่ parse ไม่ได้ ห้ามถือว่าผ่าน - validator วันนี้ ไม่มี rule สำหรับ
Scopeเลย ทั้งที่ controller รับ[FromQuery] string? scope(NotificationsController.cs:38) → เพิ่ม whitelistPersonal|Broadcast|Company+ whitelist sort key - ⚠️ raw SQL ข้าม
AuditableEntityInterceptor(เป็นSaveChangesInterceptor) — ยอมรับได้ที่นี่แต่ให้รู้ตัว
Acceptance criteria
-
?scope=' OR 1=1--→ validation error (400) ไม่มี SQL error - insert 50 row ใน
SaveChangesเดียว (CreatedAtเท่ากันทั้งหมด) → ไล่ page 1-3 → ไม่มีIdซ้ำ/หาย และTotalCountคงที่ -
?isRead=false¬ificationType=Info→ ทุก row ตรง filter และTotalCount= จำนวนหลัง filter -
EXPLAINunion page 1 → ไม่มี Sort node บน branch company - ประกาศที่
AppIdเป็น app ที่ user ไม่มีสิทธิ์ → ไม่อยู่ทั้งใน list และไม่ถูกนับใน unread - alias ใน SQL ถูก quote ทุกตัว (ตรวจด้วยสายตา + grep)
Verify: dotnet test ... + EXPLAIN
T-13 — mark read routing ด้วย Scope
| blocked-by | T-08, T-09 |
| files | ...\Features\Notification\Commands\MarkNotificationRead\MarkNotificationReadCommand.cs + ...Handler.cs + ...Validator.cs · ...\Controllers\v1\NotificationsController.cs (บรรทัด 67-76) |
| human review | ✅ (b) |
ทำอะไร: scope == "Company" → company path (SQL §7.4), อื่นๆ → personal path เดิม ไม่ต้อง probe personal ก่อน
memberCompanyIds ต้องมาจาก guard T-08 (HttpContext.GetUserInfo()) เท่านั้น — scope ที่ client ส่งมาเป็นแค่ routing hint ไม่ใช่ authz: membership guard อยู่ใน SELECT ... WHERE n."CompanyId" = ANY({memberCompanyIds}) ภายใน statement เดียวกับ INSERT จึงไม่มีช่อง TOCTOU
ห้าม
- ❌ pre-check ด้วย
AnyAsync— double-click 2 request ผ่าน check ทั้งคู่ →23505 - ❌ พึ่ง
GetForUpdateAsync—InAppNotificationRepository.cs:72-77เป็นFirstOrDefaultAsyncเฉยๆ ไม่ได้ lock row จริง แม้ชื่อจะบอกอย่างนั้น - ❌ ให้ repo เรียก
SaveChangesAsyncเอง — ปล่อยIUnitOfWorkที่ handler
Acceptance criteria
- double-click mark-read → 200 ทั้งคู่, receipt 1 row, ไม่มี 23505/25P02 ใน log
- user บริษัท A ยิง mark-read ด้วย id ของบริษัท B → 404 และ ไม่มี receipt row เกิดขึ้น
- ยัด
companyIdของ B ผ่านทุก binding surface (query / body / header / JSON field เกิน) → 0 row
Verify: dotnet test ... + integration test ที่ assert จำนวน row ใน company_notification_reads
T-14 — mark all read
| blocked-by | T-08, T-09 |
| files | ...\Features\Notification\Commands\MarkAllNotificationsRead\* + repo |
ทำอะไร: SQL §7.5 — INSERT ... SELECT ... JOIN (VALUES (companyId, joinedAt)) ... ON CONFLICT DO NOTHING
ON CONFLICTบังคับ — 2 tab กด mark-all พร้อมกันNOT EXISTSมองไม่เห็น row ที่อีก txn ค้างอยู่- ส่ง
@nowจาก C# ห้ามใช้now()ของ Postgres (= txn start time และคนละ clock กับที่อื่น) และReadAtต้องKind=Utcไม่งั้น Npgsql throw
Acceptance criteria
- 2 tab mark-all พร้อมกัน → 200 ทั้งคู่, unread = 0
- ประกาศก่อน
joinedAtไม่ถูก insert receipt - ไม่มี
now()ของ Postgres ใน SQL ของ task นี้
Verify: dotnet test ...
T-15 — detail by id + 404 negative test
| blocked-by | T-08, T-09 |
| files | ...\Features\Notification\Queries\GetNotificationDetail\GetNotificationDetailHandler.cs + ...Query.cs |
| human review | ✅ (b) |
ไม่ optional — อยู่บน FE happy path
notification.state.ts:122router.navigate([APP_ROUTE_PATHS.NOTIFICATION, item.id])→ หน้า detail เรียกgetNotificationById(notification.service.ts:77-90) ⇒ ทุกครั้งที่ user กดประกาศบริษัทที่ไม่ใช่ deeplink
ทำอะไร: SQL §7.6 — join receipt + WHERE n."Id" = @id AND n."CompanyId" = ANY(@memberCompanyIds) AND (@joinedAt IS NULL OR n."CreatedAt" >= @joinedAt)
- ไม่เจอ ⇒ 404 ไม่ใช่ 403 (กัน existence oracle)
- ❌ ห้ามทำเป็น
GetByIdAsync(id)เฉยๆ = IDOR เพราะ id ถูก broadcast เข้า group ทั้งบริษัทอยู่แล้ว
Acceptance criteria
- negative test บังคับ: user บริษัท A เปิด detail ด้วย id ของบริษัท B → 404 และ response body ไม่มี
Title/Detail/Subtitle - ประกาศที่
CreatedAt < joinedAt→ 404 - ประกาศบริษัทตัวเองปกติ → 200 พร้อม
Scope == "Company"
Verify: dotnet test tests\Notification05.Tests\Notification05.Tests.csproj (วางไว้ที่ Application\Notification\Queries\GetNotificationDetailHandlerTests.cs ที่มีอยู่แล้ว)
T-16 — AppHub join company group
| blocked-by | T-04 (ต้องรู้ว่าใช้ทาง A หรือ B), T-08, T-11 |
| files | C:\Source\AzureDevOps_SuperAPP\Backend_NotificationService\src\Notification01.API\Hubs\AppHub.cs |
| human review | ✅ (b) |
ทำอะไร: ตาม §8.1 — join company-{id} ทุกบริษัทที่ guard คืนมา แล้วส่ง UnreadCountUpdated ด้วย combined count (T-11) แทน notificationRepository.GetUnreadCountAsync เดิม (AppHub.cs:22)
companies.Count == 0→LogWarning+ ส่ง signal ให้ client รู้ว่า company scope ยังไม่พร้อม ห้าม degrade เงียบ (FE bootstrap connect ก่อนsyncUserInfoAndPerm⇒ cold blob เกิดจริง)- ห้ามเขียน remove company group ใน
OnDisconnectedAsync— SignalR ถอด connection ออกจากทุก group ให้เองเมื่อ connection จบ และการอ่านHttpContextหลัง connection จบเป็นความเสี่ยงฟรี · บรรทัด 31-33 เดิม (removeuser-) ไม่ต้องแตะ - ห้ามเพิ่ม client-invokable method ใดๆ ที่ join group ได้ — วันนี้ hub มี method เดียวที่ client เรียกได้คือ
CheckAppStatus(AppHub.cs:38) ซึ่ง filter จาก cached list เท่านั้น คุณสมบัตินี้คือสิ่งที่ทำให้ client join group เองไม่ได้
Acceptance criteria
-
rg "RemoveFromGroupAsync" AppHub.cs→ พบเฉพาะบรรทัดเดิมของuser-{userId}เท่านั้น - จำนวน public method ที่ client invoke ได้ยังเท่าเดิม (1 =
CheckAppStatus) - connect ด้วย user ที่มี 2 บริษัท → log แสดง join 2 group + ส่ง combined unread
- connect ด้วย user ที่ blob ยังไม่พร้อม → มี
LogWarning+ client ได้รับ signal
Verify: deploy dev → เปิด browser จริง (headed) → ดู log ฝั่ง service + console ฝั่ง FE
T-17 — ActionLink allowlist + config key
| blocked-by | T-10 |
| files | options class ใหม่ใน ...\src\Notification04.Domain\Configuration\ · validator ใน ...\src\Notification03.Application\ · ...\src\Notification01.API\appsettings.json + appsettings.{ENV}.json |
| human review | ✅ (c) — security |
ทำอะไร (§10.4-10.6): validate server-side ตอน write — อนุญาตเฉพาะ relative path หรือ https + host ที่อยู่ใน allowlist จาก config · reject javascript: / data: / host นอก list · Title/Subtitle/Detail เป็น text-only + max length ที่ validator
นี่คือสิ่งที่ทำให้ประกาศปลอมยกระดับจาก spam เป็น account takeover — ถึงทางเข้าจะเป็น ASB แล้ว (D-3) task นี้ก็ยังต้องเสร็จก่อนเปิด consumer เพราะ producer ที่ถูก compromise ก็ยัด link ได้
Acceptance criteria
- unit test:
javascript:alert(1)→ reject - unit test:
data:text/html,...→ reject - unit test:
https://evil.example/x(นอก allowlist) → reject - unit test:
/application-tracking?appId=X(relative) → accept - unit test: https + host ใน allowlist → accept
- config key ใหม่มีอยู่ครบใน
appsettings.json+ ทุกappsettings.{ENV}.jsonของ service
Verify: dotnet test tests\Notification05.Tests\Notification05.Tests.csproj
T-18 — IaC parity ของ config key ใหม่
| blocked-by | T-17 |
| files | C:\Source\AzureDevOps_SuperAPP\Backend_Iac\config\{notification-service-folder}\{dev,sit,uat,prod}\appsettings.json — ยืนยันชื่อ folder จริงของ notification-service ก่อนแก้ ห้ามเดา |
ทำไมบังคับ: CI overlay cp ไฟล์จาก IaC ทับ appsettings.{ENV}.json ของ service ตอน build เข้า image (full-file replace ไม่ merge) ⇒ แก้แต่ใน service repo = ไม่มีผลบน cluster และ guard check-config-parity.py จะทำ build FAIL ถ้า IaC ขาด key ที่ service มี
Acceptance criteria
- key ของ T-17 มีอยู่ใน IaC ครบทุก env (dev/sit/uat/prod) — ขาด env ไหน env นั้นพังตอน deploy
- ค่า allowlist host ต่อ env ถูกต้อง (dev ≠ uat ≠ prod) — ถ้าไม่รู้ค่าจริงของ env ไหน → บอก Owner ว่าต้องเติมที่ env ไหนบ้าง อย่าปล่อยเงียบ อย่าใส่ค่ามั่ว
-
[config-parity]gate ผ่าน
Verify: รัน pipeline / check-config-parity.py
7. Phase 5 — Frontend_HostAppSuperApp
✅ ไม่ต้องแก้และไม่ต้อง publish
@exim/util-sdk—statesignal +subscribe()+connect/disconnectครบแล้ว และแอปเข้าถึงconnection.invoke()ตรงได้อยู่แล้ว (realtime-app-connection.service.ts:128-141)
T-19 — model + mapper เก็บ scope
| blocked-by | — (ทำได้เลย ไม่รอ BE) |
| files | ...\apps\shell\src\app\services\notification-service\notification\notification.model.ts · ...\notification.service.ts |
ทำไมทำได้เลย: scope อยู่บน wire แล้ว (notification.model.ts:40 optional, :48 NotificationApiItem.scope: string, :69 ใน detail) และ BE NotificationItemDto ก็ส่ง Scope มาอยู่แล้ว — มันแค่ถูก mapper ทิ้ง ⇒ ไม่มี BE dependency
ทำอะไร
- เพิ่ม
scopeเข้า interfaceNotificationItem(notification.model.ts:7-21) mapNotificationItem(notification.service.ts:124-135) และmapNotificationDetail(:137-152) — carryscopeต่อ (ปัจจุบันทิ้งทั้งคู่)
🛑 ห้ามเพิ่ม appCode/appId ฝั่ง FE เลย — D-5 กรองที่ server แล้ว FE ได้รับเฉพาะของที่ user มีสิทธิ์ ⇒ เพิ่ม field มาก็ไม่มีใครใช้ และจะล่อให้คนไปทำ filter ซ้ำที่ client
Acceptance criteria
-
NotificationItemมีscope: string - unit test: item ที่ map แล้วมี
scopeตรงกับที่ API ส่งมา -
rg "appCode|appId" apps\shell\src\app\services\notification-service= 0 hit
Verify
cd C:\Source\AzureDevOps_SuperAPP\Frontend_HostAppSuperApp
pnpm test:shell
npx nx lint shell
T-20 — markAsRead ส่ง scope
| blocked-by | T-13, T-19 |
| files | ...\notification.service.ts (markAsRead บรรทัด 106-113) · ...\apps\shell\src\app\pages\notification\notification.state.ts (บรรทัด 100 และ 125-134) |
ทำอะไร: markAsRead(id, scope) แล้วส่ง scope ไป BE เพื่อ route ตาม §7.4 (BE ไม่ต้อง probe personal ก่อน) · handleItemClick (notification.state.ts:97) ส่ง item.scope ต่อ
Acceptance criteria
- request ที่ยิงออกมี scope จริง (ดูจาก Network tab บน headed browser)
- กดประกาศบริษัท → BE เกิด row ใน
company_notification_reads(query ตรวจ) - กด personal notification → ยังทำงานเหมือนเดิมไม่ regress
Verify: pnpm test:shell + headed browser + DB read (read-only)
T-21 — thundering herd + FE constraint guard
| blocked-by | T-19 |
| files | ...\apps\shell\src\app\services\realtime\realtime-app-connection.service.ts (บรรทัด 71-107) · ...\pages\notification\notification.state.ts (บรรทัด 30-38) |
ปัญหาที่ verify แล้ว: ทุก event Notification ทำ 2 อย่างพร้อมกัน — notification.state.ts:31-37 effect เรียก loadInitial() (refetch หน้า 1 + items.set([])) และ realtime-app-connection.service.ts:76 เรียก loadUnreadCount() ⇒ ประกาศบริษัท 1 ใบ = 2 × N REST request ภายในวินาทีเดียว (N = สมาชิกที่ online) · personal push ไม่เคยเจอเพราะยิงทีละคน
ทำอะไร — เลือกอย่างน้อย 1 (แนะนำข้อ 2):
- ใส่ jitter/debounce ก่อน refetch
- บวก badge ใน local state แทนการ re-query แล้วค่อย refetch ตอนผู้ใช้เปิดกระดิ่งจริง
⛔ constraint ที่ห้ามละเมิด
- การจัดการ company ต้องอยู่ใน subscription เดิม ของ
RealtimeAppConnectionService—signalr-realtime.client.tsทำhandlers.set(channel, handler)⇒ ถ้ามีคนที่ 2subscribe('Notification', ...)ตัวแรกจะถูกทับและตายเงียบหลัง reconnect - ห้ามเรียก
destroyCachedClients()— มันแค่ clear map ไม่ stop socket และไม่รักษา subscriber
Acceptance criteria (greppable)
-
rg "subscribe\('Notification'" apps\shell→ นับได้ 1 (ตัวเดิมที่realtime-app-connection.service.ts:72) -
rg "destroyCachedClients" apps\shell→ 0 hit - วัดจริง: ประกาศ 1 ใบเข้ามา → REST request ต่อ client ≤ 1 (เดิม 2)
- reconnect → list + count ตรงกับ DB
Verify
cd C:\Source\AzureDevOps_SuperAPP\Frontend_HostAppSuperApp
rg -c "subscribe\('Notification'" apps\shell
rg -c "destroyCachedClients" apps\shell
pnpm test:shell
+ headed browser: เปิด Network tab นับ request ตอนมี event เข้า
T-22 — UI รองรับ scope === 'Company'
| blocked-by | T-19 |
| files | ...\apps\shell\src\app\pages\notification\notification.html · notification.ts · notification.message.ts |
ทำอะไร: แสดง icon/label แยกสำหรับ scope === 'Company' ในลิสต์
🛑 ห้ามเพิ่มมิติ filter ใหม่ — NotificationFilter วันนี้คือ 'all' | 'unread' (notification.model.ts:5) การให้เลือกดูทีละบริษัทไม่อยู่ใน D-1 ที่ Owner เลือก ⇒ ยังไม่ต้องทำ
Acceptance criteria
- item ที่
scope === 'Company'แสดงต่างจาก personal อย่างเห็นได้ -
NotificationFilterยังเป็น'all' | 'unread'เท่าเดิม - i18n key ใหม่ผ่าน
pnpm validate:i18n
Verify: pnpm validate:i18n + pnpm test:shell + screenshot จาก headed browser
8. Phase 6 — ASB consumer + E2E
T-24 — CompanyNotificationConsumer (ASB)
| blocked-by | T-10 · deploy รอ X-1 (ชื่อ topic + contract) |
| files | ...\src\Notification02.Infrastructure\Messaging\CompanyNotificationConsumer.cs (ใหม่) · ...\src\Notification04.Domain\Messages\CompanyNotificationEvent.cs (ใหม่) · ...\src\Notification02.Infrastructure\DependencyInjection.cs (บล็อก consumer บรรทัด 216-224) |
| human review | ✅ (c) |
🔒 D-3: ASB เท่านั้น — ห้ามสร้าง HTTP endpoint สำหรับสร้างประกาศบริษัทเด็ดขาด ไม่ว่าจะ “ชั่วคราวเพื่อทดสอบ” หรืออะไรก็ตาม ถ้ารู้สึกว่าต้องมี = หยุดแล้วรายงาน
ทำอะไร: ลอก pattern จาก FlowStatusNotificationConsumer.cs ทั้งโครง —
private const string TopicName = "<X-1>"; // รอ X-1 ห้ามเดา
private const string SubscriptionName = "<X-1>"; // รอ X-1 ห้ามเดา
_processor = _client.CreateProcessor(TopicName, SubscriptionName, new ServiceBusProcessorOptions());
แล้ว register ต่อจากบรรทัด 219 ด้วย services.AddHostedService<Messaging.CompanyNotificationConsumer>();
- handler →
SendCompanyAsync(companyId, payload, appId, externalMessageId, ct)(T-10) externalMessageId= id ของ event จาก producer — เป็นตัวที่ทำให้ redelivery ไม่สร้าง row ซ้ำ (ผ่าน unique index(ExternalMessageId, CompanyId))SourceServicederive จาก consumer เอง (ค่าคงที่ของ consumer นี้) ห้ามอ่านจาก message body — ไม่งั้น producer ปลอม audit ได้- error handling ตาม
FlowStatusNotificationConsumer.cs:125-130— abandon ตอน transient (redelivery ปลอดภัยแล้วเพราะ upsert)
ถ้ายังไม่ได้ X-1: เขียน consumer + contract record + unit test ของ handler ได้ปกติ แต่ ห้าม hardcode ชื่อ topic ที่เดาเอง ให้ใส่ placeholder ที่ throw ชัดเจนตอน startup แล้วรายงานว่าติด X-1
Acceptance criteria
-
rg -n "HttpPost" src\Notification01.API\Controllers— ไม่มี route ใหม่สำหรับประกาศบริษัท (grep ยืนยัน) - consumer register ใน DI แล้ว service ขึ้นได้
- unit test ของ handler: message เดียวกันส่งซ้ำ → row เดียว, push ครั้งเดียว
-
SourceServiceไม่ได้มาจาก message body (อ่านโค้ดยืนยัน) - topic/subscription เป็นค่าจริงจาก X-1 ไม่ใช่ค่าที่เดา
Verify: dotnet test tests\Notification05.Tests\Notification05.Tests.csproj + startup ขึ้นไม่ throw
T-23 — E2E (headed)
| blocked-by | X-1 + T-22 + T-24 |
ทำไมยังบล็อก: กฎ E2E ของ repo นี้คือ ห้าม seed ห้ามยิง API สร้างข้อมูลทดสอบ ต้องเปิด browser จริง (headed) กดผ่านหน้าจอทุก step ⇒ ต้องมีประกาศจริงไหลมาจาก ASB topic จริง ห้าม insert row ลง DB เอง ห้ามสร้าง endpoint ชั่วคราว
เมื่อปลดบล็อกแล้ว AC = §13 หมวด FE ของ SPEC_V2: ประกาศเข้ามาไม่เกิด refetch storm · reconnect แล้ว list+count ตรง DB · กดประกาศบริษัทเข้าหน้า detail ได้ไม่ 404 · scope ไหลจาก API ถึง UI จริง · ประกาศของ app ที่ user ไม่มีสิทธิ์ไม่โผล่ทั้งใน list และใน SignalR frame (D-5)
9. Human review checkpoints (3 จุด — คนละเรื่องกับ “Owner ตอบ STOP gate”)
STOP gate = การตัดสินใจของ Owner · checkpoint = การ review โค้ด/ออกแบบโดยคน ก่อน merge — ผ่าน gate แล้วยังต้องผ่าน checkpoint
| จุด | เกิดเมื่อ | ตรวจอะไร | |
|---|---|---|---|
| (a) | schema | ก่อน รัน dotnet ef migrations add (T-07) | คอลัมน์ app เป็น AppId uuid ตาม D-5 · index 3 ตัวครบ + shape ถูก · unique partial index · FK cascade · ไม่มี business logic ปนบน MIGRATION |
| (b) | authorization | ก่อน merge T-03, T-06, T-08, T-13, T-15, T-16 | §5 กฎ 1-4: company id derive จาก GetUserInfo() เท่านั้น · ไม่มี [FromBody] บน field ที่เป็น authz · repo ไม่รับ companyId นอก guard · ไม่มีบริษัท ⇒ 0 row (short-circuit ใน C# ไม่ใช่ SQL predicate) |
| (c) | security | ก่อน merge T-10, T-17, T-24 | SourceService derive จาก consumer ไม่ใช่ message body · ActionLink allowlist · log ไม่มี payload/PII · ไม่มี HTTP route ใหม่สำหรับประกาศบริษัทเลย (D-3) · AppId กรองที่ server และค่าที่ parse ไม่ได้ถูกทิ้ง (D-5) |
10. สิ่งที่แผนนี้ ไม่ ครอบคลุม (และทำไม)
| ไม่ทำ | เหตุผล |
|---|---|
| ชื่อ topic + message contract ของ ASB | X-1 — ต้องตกลงกับทีมเจ้าของ event; เขียน consumer ได้แล้ว แต่ห้ามเดาชื่อ topic แล้ว deploy |
| HTTP endpoint สำหรับสร้างประกาศบริษัท | D-3 ตัดสินว่า ไม่มี — ไม่ใช่ “ยังไม่ทำ” แต่คือ ห้ามมี |
| retention job / watermark cursor ของ receipt table | D-4 ตัดสินว่าไม่ทำตอนนี้ — รับ trade-off ว่า receipt ไม่มีเพดานระยะยาว แลกกับความง่าย; เฝ้าขนาดตาราง แล้วเติม retention ทีหลังได้โดยไม่แตะ schema |
| §5 กฎ 6 — bound WS exposure | ⚠️ ช่องที่รู้ตัวและจงใจเลื่อน: group membership ถูก set ตอน connect เท่านั้น (T-16) ⇒ connection ที่เปิดค้างยังได้ push ของบริษัทที่ user ถูกถอดสิทธิ์ไปแล้ว จนกว่าจะ reconnect · การตัด/re-check connection กลางคันต้องออกแบบเพิ่ม (ต้องรู้ก่อนว่า invalidate จาก UserService จะ propagate ถึง NS ทางไหน) — ต้องยกให้ Owner ตัดสินก่อนเปิด feature จริง ไม่ใช่ปล่อยผ่านเงียบ |
| E2E เต็ม flow | ต้องมีข้อมูลจาก ASB topic จริง (ห้าม seed / ห้ามยิง API สร้างข้อมูล) ⇒ ผูกกับ X-1 |
| announcement authoring UI / targeting รายบุคคล | นอกขอบเขตที่ SPEC_V2 §1 ประกาศไว้ |
| filter ดูทีละบริษัทบนกระดิ่ง | D-1 เลือก all-companies ⇒ ไม่ต้องมีมิติ filter ใหม่ (§9.4) |
แก้ / publish @exim/util-sdk | §9 ยืนยันแล้วว่า API ที่ต้องใช้มีครบ — ตราบใดที่ไม่ละเมิด constraint §9.1 ก็ไม่ต้องแตะ lib |
reconnect flow ตอนสลับบริษัท + corporate-panel.state.ts | หายไปทั้งหมดเพราะ D-1 · จะกลับมาก็ต่อเมื่อ Owner พลิก D-1 กลับเป็น active-company-only (ตอนนั้นต้องอ่าน §9.5 ก่อน โดยเฉพาะข้อห้ามเปลี่ยน tap() → switchMap()) |
| แตะ personal notification path เดิม | §1 ประกาศชัดว่าไม่แตะ — ยกเว้นจุดเดียวที่เลี่ยงไม่ได้คือ UnreadCount เปลี่ยนความหมายเป็น combined (T-11) ซึ่งเป็น behavioral break ที่ประกาศไว้แล้ว |