Private Docs

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 ที่แตะ

repoabsolute pathbranch ตั้งต้น
Backend_PackageC:\Source\AzureDevOps_SuperAPP\Backend_Packagedevelopment
Backend_UserServiceC:\Source\AzureDevOps_SuperAPP\Backend_UserServicedevelopment
Backend_NotificationServiceC:\Source\AzureDevOps_SuperAPP\Backend_NotificationServicedevelopment (+ MIGRATION มีอยู่แล้ว)
Frontend_HostAppSuperAppC:\Source\AzureDevOps_SuperAPP\Frontend_HostAppSuperAppdevelopment
Backend_IacC:\Source\AzureDevOps_SuperAPP\Backend_Iacdevelopment

0. กฎเหล็ก — อ่านก่อนหยิบ task ใดก็ตาม

  1. 🛑 STOP gate ยังไม่มีคำตอบ = หยุดและรายงาน ห้ามแก้ด้วยการ “เลือกตัวเลือกที่ compile ผ่าน” / “เลือกอันที่ดูสมเหตุสมผล” / “ทำไปก่อนแล้วค่อยแก้” ถ้า task บอก blocked-by: S-x และ S-x ยังว่าง → รายงานกลับว่าติด gate ไหน แล้วหยุด ไม่ต้องหยิบ task ถัดไปที่ blocked เหมือนกัน
  2. Migration ต้อง author บน branch MIGRATION เท่านั้น — มี migration task เดียวในแผนนี้ (T-07) ห้ามรัน dotnet ef migrations add บน feature branch ห้าม cherry-pick migration+snapshot ข้าม branch
  3. ห้ามลอก test-push / test-broadcast (NotificationsController.cs:88-110) เป็นแม่แบบของ write path ใดๆ — มันมีแค่ [Authorize] ระดับ class ดูรายละเอียดที่ §1 S-1
  4. ทุก task ต้องจบด้วย verification ที่รันจริง ห้าม claim ผ่านโดยไม่มี output — “น่าจะผ่าน” ไม่นับ
  5. ไม่แตะโค้ดนอก files ของ task — ไม่ refactor เพื่อนบ้าน ไม่จัด format ไฟล์เดิม
  6. FE: ห้ามละเมิด 2 ข้อนี้ทุกกรณี (SPEC_V2 §9.1) — subscribe('Notification', ...) ต้องมี subscriber เดียวทั้งแอป (ตัวที่มีอยู่คือ realtime-app-connection.service.ts:72) และ ห้ามเรียก destroyCachedClients()

1. สถานะ gate

1.1 ✅ ตัดสินแล้ว (ใช้ได้เลย)

IDสรุป
D-1scope กระดิ่ง = ทุกบริษัทที่ 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 ใน blobT-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)

#taskphaseblocked-byhuman review
T-01Backend_Package: UserCompanyInfo.JoinedAt1
T-02publish SupApp_util_lib + bump 2 consumer (cross-repo gate)1T-01
T-03P-3 ปิดช่อง re-warm ทับ invalidation1✅ (b)
T-04P-4 probe: GetUserInfo() ใน OnConnectedAsync1
T-05UserService: map JoinedAt2T-02
T-06P-2 membership-change invalidation2T-02✅ (b)
T-07migration เดียวของแผนนี้3✅ ปลดล็อกแล้ว (D-4 + D-5)✅ (a)
T-08§5 authorization guard ResolveMemberCompanies4T-02✅ (b)
T-09repository + port + DbSet + DI4T-07
T-10write path SendCompanyAsync + NotifyCompanyAsync4T-09✅ (c)
T-11combined unread count (§7.1 / §7.3)4T-08, T-09
T-12union list query + validator whitelist (§7.2)4T-08, T-09
T-13mark-read routing ด้วย Scope (§7.4)4T-08, T-09✅ (b)
T-14mark-all read (§7.5)4T-08, T-09
T-15detail by id + 404 negative test (§7.6)4T-08, T-09✅ (b)
T-16AppHub join company group (§8.1)4T-04, T-08, T-11✅ (b)
T-17ActionLink allowlist + config key (§10.4)4T-10✅ (c)
T-18IaC parity ของ config key ใหม่ (cross-repo)4T-17
T-19FE model/mapper เก็บ scope (§9.2)5
T-20FE markAsRead ส่ง scope (§9.2)5T-13, T-19
T-21FE thundering herd (§9.3) + constraint guard (§9.1)5T-19
T-22FE UI รองรับ scope === 'Company' (§9.4)5T-19
T-23E2E headed (§13 FE)6X-1 + T-22, T-24
T-24ASB consumer (CompanyNotificationConsumer)6T-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
filesC:\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 มี property JoinedAt type DateTime? + [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-byT-01
filesBackend_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

ทำอะไร

  1. bump version ของ SupApp_util_lib
  2. dotnet packแตก .nupkg ออกมาตรวจว่า DLL ข้างในมี symbol JoinedAt จริงก่อน push (dotnet pack เคย pack bin เก่า และ feed เป็น immutable — push version เสียแล้วแก้ไม่ได้ ต้อง bump ใหม่)
  3. 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
  4. 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 ได้)
filesC:\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
filesC:\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:109 app.UseRedisUserInfo() มาก่อน :135 app.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-byT-02
filesC:\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 = 1ToString() ได้ "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-byT-02
filesBackend_UserService — ทุกจุดที่ mutate UserCompanyMapping (add / remove / เปลี่ยน status) — ต้อง enumerate เองก่อน ห้ามเดารายชื่อไฟล์
human review✅ (b)

ทำอะไร

  1. rg หา write site ทั้งหมดของ UserCompanyMapping (add / remove / soft-delete / เปลี่ยน IsStatus) แล้ว ทำลิสต์ให้ครบก่อนแก้
  2. ทุก site ต้อง invalidate Redis blob key ของ user ที่กระทบ
  3. เขียนไว้ใน 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 เอง (base Entity ไม่ define Id — ดู InAppNotification.cs:6-8 เป็นตัวอย่างจริงในโค้ดนี้)
  • ห้ามตัด IAuditableEntity แม้ entity จะไม่มี mutator — AuditableEntityInterceptor filter ด้วย Entries<IAuditableEntity>() ⇒ ตัด interface = ไม่มีใคร set CreatedBy ที่ประกาศ .IsRequired() = NOT NULL violation ตอน insert
  • CompanyNotificationRead เป็น POCO composite key ไม่ derive Entity
  • 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 partial IX_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-byT-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 ถัดๆ ไปมีที่อ้าง:

  1. company id derive จาก HttpContext.GetUserInfo() เท่านั้น — ห้าม model-bind จาก query/body/header
  2. command/query record ที่มี UserId/company ids ต้อง construct ใน controller เอง ห้าม [FromBody]
  3. repo method ห้ามรับ companyId ที่ไม่ผ่าน guard นี้
  4. ไม่มีบริษัท ⇒ 0 row เป๊ะ short-circuit ใน C# — ห้ามเขียนเป็น SQL predicate แบบ @x IS NULL OR ...
  5. revocation lag = อายุ blob ที่เหลือ (พึ่ง T-03 + T-06)
  6. 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-byT-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 ไม่ตรง → runtime InvalidOperationException) หมายเหตุ: 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-byT-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 + implendpoint 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 + FlowStatusNotificationConsumer abandon ตอน transient ⇒ redelivery ปกติ; 2 consumer เห็น EXISTS=false พร้อมกันได้ (READ COMMITTED)
  • catch (PostgresException ... 23505) — ถ้ามี ambient transaction จาก TransactionBehavior txn abort ไปแล้วตอน catch และ EnableRetryOnFailure ถูกปิดโดยเจตนา (DependencyInjection.cs:64-67) ไม่มี retry มารองรับ
  • ❌ dedup key ที่ไม่มี CompanyIdWorkflowMakerDispatcher.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 ไม่ถูกเรียกจาก ICommand handler ใดๆ (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-byT-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 == 0return 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-byT-08, T-09
filesCompanyNotificationRepository (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) → เพิ่ม whitelist Personal|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&notificationType=Info → ทุก row ตรง filter และ TotalCount = จำนวนหลัง filter
  • EXPLAIN union 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-byT-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
  • ❌ พึ่ง GetForUpdateAsyncInAppNotificationRepository.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-byT-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-byT-08, T-09
files...\Features\Notification\Queries\GetNotificationDetail\GetNotificationDetailHandler.cs + ...Query.cs
human review✅ (b)

ไม่ optional — อยู่บน FE happy path notification.state.ts:122 router.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-byT-04 (ต้องรู้ว่าใช้ทาง A หรือ B), T-08, T-11
filesC:\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 == 0LogWarning + ส่ง signal ให้ client รู้ว่า company scope ยังไม่พร้อม ห้าม degrade เงียบ (FE bootstrap connect ก่อน syncUserInfoAndPerm ⇒ cold blob เกิดจริง)
  • ห้ามเขียน remove company group ใน OnDisconnectedAsync — SignalR ถอด connection ออกจากทุก group ให้เองเมื่อ connection จบ และการอ่าน HttpContext หลัง connection จบเป็นความเสี่ยงฟรี · บรรทัด 31-33 เดิม (remove user-) ไม่ต้องแตะ
  • ห้ามเพิ่ม 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


blocked-byT-10
filesoptions 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-byT-17
filesC:\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-sdkstate signal + 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 เข้า interface NotificationItem (notification.model.ts:7-21)
  • mapNotificationItem (notification.service.ts:124-135) และ mapNotificationDetail (:137-152) — carry scope ต่อ (ปัจจุบันทิ้งทั้งคู่)

🛑 ห้ามเพิ่ม 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-byT-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-byT-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):

  1. ใส่ jitter/debounce ก่อน refetch
  2. บวก badge ใน local state แทนการ re-query แล้วค่อย refetch ตอนผู้ใช้เปิดกระดิ่งจริง

⛔ constraint ที่ห้ามละเมิด

  • การจัดการ company ต้องอยู่ใน subscription เดิม ของ RealtimeAppConnectionServicesignalr-realtime.client.ts ทำ handlers.set(channel, handler) ⇒ ถ้ามีคนที่ 2 subscribe('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\shell0 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-byT-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-byT-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))
  • SourceService derive จาก 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-byX-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-24SourceService derive จาก consumer ไม่ใช่ message body · ActionLink allowlist · log ไม่มี payload/PII · ไม่มี HTTP route ใหม่สำหรับประกาศบริษัทเลย (D-3) · AppId กรองที่ server และค่าที่ parse ไม่ได้ถูกทิ้ง (D-5)

10. สิ่งที่แผนนี้ ไม่ ครอบคลุม (และทำไม)

ไม่ทำเหตุผล
ชื่อ topic + message contract ของ ASBX-1 — ต้องตกลงกับทีมเจ้าของ event; เขียน consumer ได้แล้ว แต่ห้ามเดาชื่อ topic แล้ว deploy
HTTP endpoint สำหรับสร้างประกาศบริษัทD-3 ตัดสินว่า ไม่มี — ไม่ใช่ “ยังไม่ทำ” แต่คือ ห้ามมี
retention job / watermark cursor ของ receipt tableD-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 ที่ประกาศไว้แล้ว