In-App Notification (Company) — Plan C + ผลรีวิวเทียบโค้ดจริง
Plan C shared-row + company group สำหรับกระดิ่งระดับบริษัท พร้อมผลรีวิวเทียบ Backend_NotificationService จริง — 5 blocker (idempotency key, race ไม่มี ON CONFLICT, membership cache TTL 24 ชม. เป็น authz boundary, trigger ไม่มี authz, union query bug 6 จุด), risk register, ของที่แก้ให้แล้ว และ Definition of Ready
อัปเดต: 2026-07-30
Plan C — Company Notification แบบ Shared-Row + Company Group (ละเอียด)
สิ่งที่ได้: กระดิ่ง/inbox ระดับ company ที่ครบเครื่อง (มี unread count, เห็นย้อนหลัง, offline ได้, mark-read) โดยไม่ต้องพึ่ง UserService reverse-lookup และรองรับ คนเพิ่งเข้าบริษัทให้เห็นประกาศเก่า
แลกกับ: ต้องรื้อ read model (ตารางใหม่ 2 ตัว + เขียน query ใหม่) — ใช้
IsReadเดิมตรงๆ ไม่ได้อ่านพื้นฐาน:
SignalR-Realtime.md(Backend_NotificationService) · เทียบทางเลือก A/B/C: (ในแชท)
🔴 ผลรีวิวเทียบโค้ดจริง (30/07/2026) — ยังไม่ควรเริ่ม implement
รีวิวเอกสารนี้เทียบ
Backend_NotificationService@75df653+Backend_Packageจริง verdict:replan— data model ถูกทาง แต่ constraint layer หายทั้งหมด และมี 5 blocker ที่ทำให้พังใต้ concurrency ที่เกิดขึ้นจริง (ASB redelivery / double-click / 2 tab) หรือเปิดช่อง authz
# Blocker ผลถ้าไม่แก้ B1 idempotency key เป็น global ExternalMessageId(§4.2)event เดียวส่งหลายบริษัท → บริษัทที่ 2..N หายเงียบ B2 check-then-insert ทุกจุด ไม่มี unique constraint / ON CONFLICT(§4.2, §6.4, §6.5)ประกาศซ้ำ + 23505→ txn abort → 500B3 membership cache = authorization boundary ที่ TTL 24 ชม. (§6, §7.3) คนถูกถอดจากบริษัทยังอ่านได้ ≤24 ชม. + จุดขายข้อ 3 ใช้ไม่ได้จริง B4 trigger ยังไม่ตัดสิน + ไม่มี authz model (§9) ถ้าลอก test-push→ ใครก็ยิงประกาศปลอม + phishing link เข้าทั้งบริษัทได้B5 §6.2 union query มี correctness bug 6 จุด runtime exception / paging ซ้ำ-หาย / TotalCountผิดเงียบรายละเอียดครบ + risk register + ของที่แก้ให้แล้ว → §12–§17 ท้ายเอกสาร ส่วนที่สเปกทำถูกอยู่แล้ว (อย่ารื้อ) → §12.0
1. หลักการ 3 ข้อ
- Live delivery = company group — connection join
company-{activeCompanyId}ตอน connect (อ่านDefaultCompanyIdจาก RedisUserInfo) → push เข้า group นั้น สมาชิก self-register เอง ไม่ต้องถาม UserService ว่าใครอยู่บริษัทไหน - Persistence = shared row — เก็บ 1 row ต่อ 1 ประกาศ (ไม่ fan-out) + ตาราง read-receipt แยกว่าใครอ่านแล้ว
- Unread = forward lookup — นับ “ประกาศของบริษัทฉัน ที่ฉันยังไม่อ่าน” โดย “บริษัทฉัน” อ่านจาก RedisUserInfo ตัวเอง → คนเพิ่งเข้าบริษัทก็เห็นย้อนหลัง (เช็ค membership ตอนอ่าน ไม่ใช่ตอน push)
🔑 Design decision: scope กระดิ่งที่ “บริษัท active” (แนะนำ)
กระดิ่งจะโชว์ประกาศของ บริษัทที่ active อยู่ (DefaultCompanyId) เท่านั้น + personal เดิม → สอดคล้องกับ “1 connection = 1 บริษัท” และทำให้ flow สลับบริษัท (§7) สม่ำเสมอ
ทางเลือก: ถ้าธุรกิจอยากเห็น “ทุกบริษัทที่สังกัดในกระดิ่งเดียว” → เปลี่ยน filter จาก
= DefaultCompanyIdเป็นIN Companies(แต่ต้องยอมรับว่า live group จะ scope active แต่ list เห็นทุกบริษัท = ไม่ consistent) — เอกสารนี้ยึด active-company
⚠️ [REVIEW H-01] เหตุผลที่ปัด
IN Companies(“ไม่ consistent”) จริงเฉพาะเมื่อ live group scope active-only — ถ้า join group ทุกบริษัทที่สังกัด (ปกติ 1-3 group) แล้ว filter listIN Companiesทั้งคู่ก็ consistent และ §7 ทั้งหัวข้อหายไปพร้อมกัน (ไม่ต้อง reconnect, ไม่ต้องพึ่ง UserService invalidatesetDefaultCompany, FE เหลือแค่ appCode filter) — ตัวเลือก active-only เป็นตัว สร้าง blocker B3 ของตัวเอง ดู §12 H-01 และ §16
2. Data Model (ตารางใหม่ 2 ตัว)
2.1 company_notifications — 1 row ต่อ 1 ประกาศ
| คอลัมน์ | ชนิด | หมายเหตุ |
|---|---|---|
| Id | Guid PK | |
| CompanyId | Guid | บริษัทเป้าหมาย |
| AppCode | string? (50) | null = ทุก app (client กรอง) |
| Title, Subtitle | string | |
| SpecialDetail, Detail | string? | |
| NotificationType | string | เดิม (Info/…) |
| ActionLink | string? | |
| SourceService | string? | audit |
| ExternalMessageId | string? | idempotency |
| CreatedAt, CreatedBy | audit |
Index: (CompanyId, CreatedAt) — ดึงประกาศของบริษัทเรียงเวลา
⚠️ [REVIEW B1 + H-10] index นี้ ไม่พอ และ ขาด unique constraint สำหรับ dedup
- ต้องเป็น
(CompanyId, CreatedAt DESC, Id DESC)— เพราะ tiebreakerIdต้องอยู่ใน index ไม่งั้น planner ต้อง Sort ทั้ง partition ทุกครั้งที่เปิดหน้าถัดไป- ต้องเพิ่ม partial UNIQUE
(ExternalMessageId, CompanyId) WHERE ExternalMessageId IS NOT NULL— ไม่มีตัวนี้ idempotency ใน §4.2 เป็นแค่ advisory (ดู B1/B2)AppCodeไม่ต้องใส่ใน index (predicate เป็นIS NULL OR =ใช้ prefix ไม่ได้) DDL ที่แก้ให้แล้วครบ → §13.1
2.2 company_notification_reads — read-receipt (insert เมื่ออ่าน)
| คอลัมน์ | ชนิด | หมายเหตุ |
|---|---|---|
| CompanyNotificationId | Guid | FK → company_notifications |
| UserId | Guid | |
| ReadAt | DateTime |
PK ประกอบ: (CompanyNotificationId, UserId) — กันอ่านซ้ำ + lookup เร็ว
Index เสริม: (UserId, CompanyNotificationId) — ใช้ตอนคำนวณ unread ต่อ user
Model: “ไม่มี receipt = ยังไม่อ่าน” (แถวเกิดเฉพาะตอนอ่าน — ประหยัด row)
⚠️ [REVIEW H-07 — ต้องตัดสินใจ ก่อน สร้าง table เพราะเปลี่ยนทีหลังแพง]
- “ประหยัด row” จริงเฉพาะของที่ยังไม่อ่าน — ของที่อ่านแล้วโต O(users × notifications) = เท่ากับ fan-out ที่เอกสารนี้ตั้งใจหลบ
- ชนกับจุดขาย §1.3: คนเข้าใหม่เห็นย้อนหลัง = unread ของเขาคือ ประวัติทั้งหมด (1,000 ประกาศ → badge “1000”) และ §6.5 หนึ่งคลิก =
INSERTN row ไม่มีเพดาน ใน request เดียว- ไม่มี FK จริง — เอกสารเขียนแค่ comment
FK → company_notifications; EF ไม่สร้าง FK ถ้าไม่ config → purge/retention ทำให้เกิด orphan receipt ทางแก้ที่ bound ได้ (watermark cursor table) → §13.3 ✅ index เสริม(UserId, CompanyNotificationId)ไม่ redundant กับ PK — ยืนยันแล้วว่าจำเป็นจริงสำหรับ anti-join ฝั่ง user (PK leading column ผิดด้าน) เก็บไว้
3. Domain Entities
ไฟล์ใหม่: src/Notification04.Domain/Entities/Notification/CompanyNotification.cs
public class CompanyNotification : Entity, IAuditableEntity
{
public Guid Id { get; private set; }
public Guid CompanyId { get; private set; }
public string? AppCode { get; private set; }
public string Title { get; private set; } = null!;
public string Subtitle { get; private set; } = null!;
public string? SpecialDetail { get; private set; }
public string? Detail { get; private set; }
public string NotificationType { get; private set; } = NotificationTypes.Info;
public string? ActionLink { get; private set; }
public string? SourceService { get; private set; }
public string? ExternalMessageId { get; private set; }
public DateTime CreatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
private CompanyNotification() { }
public static CompanyNotification Create(
Guid companyId, string title, string subtitle, string? specialDetail, string? detail,
string notificationType, string? actionLink, string? appCode = null,
string? sourceService = null, string? externalMessageId = null)
=> new()
{
Id = Guid.NewGuid(), CompanyId = companyId, AppCode = appCode,
Title = title, Subtitle = subtitle, SpecialDetail = specialDetail, Detail = detail,
NotificationType = notificationType, ActionLink = actionLink,
SourceService = sourceService, ExternalMessageId = externalMessageId,
CreatedAt = DateTime.UtcNow
};
}
ไฟล์ใหม่: CompanyNotificationRead.cs
public class CompanyNotificationRead
{
public Guid CompanyNotificationId { get; private set; }
public Guid UserId { get; private set; }
public DateTime ReadAt { get; private set; }
private CompanyNotificationRead() { }
public static CompanyNotificationRead Create(Guid notificationId, Guid userId)
=> new() { CompanyNotificationId = notificationId, UserId = userId, ReadAt = DateTime.UtcNow };
}
- EF Config 2 ไฟล์ + migration
AddCompanyNotifications
4. WRITE path — SendCompanyAsync
4.1 Port
IInAppNotificationService (หรือ service ใหม่ ICompanyNotificationService):
Task SendCompanyAsync(
Guid companyId, InAppNotificationPayload payload, string? appCode = null,
string? sourceService = null, string? externalMessageId = null, CancellationToken ct = default);
4.2 Implementation
public async Task SendCompanyAsync(
Guid companyId, InAppNotificationPayload payload, string? appCode = null,
string? sourceService = null, string? externalMessageId = null, CancellationToken ct = default)
{
// 1. Idempotency
if (!string.IsNullOrEmpty(externalMessageId)
&& await _companyRepo.AnyByExternalMessageIdAsync(externalMessageId, ct))
return;
// 2. เขียน 1 row (ไม่ fan-out)
var notification = CompanyNotification.Create(
companyId, payload.Title, payload.Subtitle, payload.SpecialDetail, payload.Detail,
payload.NotificationType, payload.ActionLink, appCode, sourceService, externalMessageId);
await _companyRepo.AddAsync(notification, ct);
// 3. push live เข้า company group (payload ไม่มี unreadCount — client re-query REST เอง)
await _notifier.NotifyCompanyAsync(
companyId.ToString(),
"Notification", // ← reuse event เดิม
new { notification.Id, payload.Title, payload.Subtitle, payload.NotificationType,
payload.ActionLink, appCode, companyId },
ct);
}
4.3 IRealTimeNotifier.NotifyCompanyAsync
// SignalRNotifier
public async Task NotifyCompanyAsync(string companyId, string eventName, object payload, CancellationToken ct = default)
{
try { await hubContext.Clients.Group($"company-{companyId}").SendAsync(eventName, payload, ct); }
catch (Exception ex) { logger.LogWarning(ex, "[SignalRNotifier] company push failed — company-{Cid}", companyId); }
}
ทำไม live push ไม่มี
unreadCount: group push ส่งก้อนเดียวให้ทุกคน ใส่เลขรายคนไม่ได้ → client รับNotificationแล้ว เรียก RESTloadUnreadCount()เอง (ของเดิม client ทำอยู่แล้ว) → ได้เลขรายคนถูกต้องจาก DB
✅ เหตุผลข้อนี้ ถูก และเจตนา “persist ก่อน push” ก็ถูก — push ที่ถูก swallow ใน
catch(SignalRNotifier.cs:26) จึง degrade อย่างสุภาพเพราะ DB เป็น source of truth⚠️ [REVIEW C-01 — แต่ ordering นี้จริงเฉพาะบาง path และขัดกับ H-12 ที่รีวิวนี้เสนอเอง] ต้องตัดสินให้ตรงกัน:
- path ที่ไม่มี ambient transaction (ASB consumer เรียก service ตรง เช่นแบบ
BroadcastSignalConsumer): repoSaveChangesAsyncเอง = commit จริงเกิดก่อน push ⇒ ordering ถูกตามที่ §4.2 ตั้งใจ- path ที่มี ambient transaction (ผ่าน MediatR
ICommand→TransactionBehaviorBeginTransactionAsyncแล้ว commit หลัง handler return): ถ้าทำตาม H-12 (repo ไม่เรียกSaveChangesAsyncเอง ปล่อยให้ UoW จัดการ) แล้ว push จะเกิดก่อน commit ⇒ ถ้า transaction rollback ทีหลัง client จะได้ประกาศที่ไม่มีอยู่ใน DB ⇒ สองข้อนี้เป็นจริงพร้อมกันไม่ได้ ต้องเลือก: (ก) ยอมให้ write path ของ company notification commit เองแล้วไม่วิ่งใต้TransactionBehaviorหรือ (ข) ทำตาม H-12 แล้วย้าย push ออกไปหลัง commit ด้วย outbox / post-commit hook — ห้ามปล่อยให้กำกวม เพราะทั้ง §4.2 และ H-12 ถูกเขียนไว้ในเอกสารเดียวกัน⚠️ [REVIEW B1 + B2 + H-04 + M-06] แต่ code block ด้านบนมี 4 ปัญหา:
- B1 dedup key ผิด —
AnyByExternalMessageIdAsync(externalMessageId)เป็น namespace global ไม่มีCompanyId. มี precedent จริงในโค้ดนี้แล้วที่ upstream event เดียวถูก fan-out หลาย recipient ด้วยEventIdเดียว (WorkflowMakerDispatcher.cs:34ส่งexternalMessageId: e.EventId.ToString()วน recipients) → ถ้าประกาศเดียวยิงหลายบริษัท บริษัทที่ 2..N ถูก skip เงียบ ไม่มี error และของเดิมเองก็ dedup ด้วย(ExternalMessageId, RecipientUserId)ไม่ใช่ global. key ที่ถูก =(ExternalMessageId, CompanyId)- B2 check-then-insert race — ASB เป็น at-least-once + consumer
AbandonMessageตอน transient (FlowStatusNotificationConsumer.cs:129) → redelivery เป็นเรื่องปกติ; 2 consumer interleave:EXISTSเห็น false ทั้งคู่ (READ COMMITTED มองไม่เห็น row ที่ยัง uncommitted) → INSERT ทั้งคู่ = ประกาศซ้ำในกระดิ่ง. ต้องใช้INSERT ... ON CONFLICT DO NOTHINGแล้วดู affected rows (0 = duplicate → ไม่ push SignalR)- H-04 ไม่มี app scoping ฝั่ง server — §2.1 เขียน “null = ทุก app (client กรอง)” → คนในบริษัทที่ไม่มีสิทธิ์ app นั้นก็ ได้ payload ทางสาย (เปิด DevTools เห็นหมด) ⚠️ แต่แหล่งข้อมูลสำหรับ scope ยังไม่มีจริง (แก้หลัง audit):
UserAppInfoใน blob มีแค่AppId(string?) — ไม่มี fieldAppCodeเลย (UserInfoForRedis.cs:144) ขณะที่AppCodeมีอยู่ใน app-status contract คนละตัว ⇒ “ใช้Appsจาก blob scope ที่ server ได้” อาศัยสมมติว่าAppId ≡ AppCodeซึ่งยังไม่มีหลักฐาน ⇒ ยกเป็น open question ใน Task 0: mappingAppId ↔ AppCodeมีจริงไหม ถ้าไม่มี ต้องหาแหล่ง entitlement อื่นก่อนจะ scope ฝั่ง server ได้- M-06
SourceServicespoofable — เป็น parameter จาก caller แต่ถูก label ว่า audit → ต้อง derive จาก authenticated caller identity ไม่ใช่รับจาก body
5. CONNECT — join company group + ส่ง count
ไฟล์: src/Notification01.API/Hubs/AppHub.cs
public override async Task OnConnectedAsync()
{
var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
if (!string.IsNullOrEmpty(userId))
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}"); // เดิม
// ── ใหม่: join company group (บริษัท active) ──
var info = Context.GetHttpContext()?.GetUserInfo();
var activeCompanyId = info?.DefaultCompanyId;
if (activeCompanyId is not null)
await Groups.AddToGroupAsync(Context.ConnectionId, $"company-{activeCompanyId}");
// ── count รวม (personal + company active) ──
if (!string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out var userGuid))
{
var unreadCount = await _unreadService.GetCombinedUnreadAsync(userGuid, activeCompanyId, Context.ConnectionAborted);
await Clients.Caller.SendAsync("UnreadCountUpdated", new { unreadCount }, Context.ConnectionAborted);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
if (!string.IsNullOrEmpty(userId))
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{userId}");
var info = Context.GetHttpContext()?.GetUserInfo();
if (info?.DefaultCompanyId is { } cid)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"company-{cid}");
await base.OnDisconnectedAsync(exception);
}
ต้อง inject
IUserInfoผ่านGetHttpContext()+ combined unread service เข้าAppHub(ตอนนี้ AppHub รับIInAppNotificationRepository,IAppStatusRedisReader) ⚠️ ต้องพิสูจน์ว่าGetUserInfo()ตอน WS connect ไม่ null (Task 0 — §9)
⚠️ [REVIEW H-02 — สมมติฐาน load-bearing ที่สุดของแผนทั้งหมด] ข่าวดี:
app.UseRedisUserInfo()อยู่บรรทัด 109 และMapHubบรรทัด 135 ของMiddlewarePipelineExtensions.cs→ middleware run ก่อน hub จริง ✅ ข่าวร้าย 3 ข้อ:
- วิธีพิสูจน์ยังไม่ถูกกำหนด และพิสูจน์บน localhost ไม่มีความหมาย —
FallbackEndpointเป็น cluster DNS (http://user-service.superappdev.svc.cluster.local/...,appsettings.Development.json:96) ที่เครื่อง dev เรียกไม่ถึง และFallbackApiKeyว่างในไฟล์ (มาจาก KV env var ตอน runtime) → cache miss บนเครื่อง dev ซ่อมตัวเองไม่ได้ ต้องพิสูจน์ผ่าน dev cluster บน cold cacheHttpContext.Itemsไม่ใช่สัญญาที่ปลอดภัยกับ long-lived connection — patternGetUserInfo()ออกแบบมาสำหรับ per-request controller; ถ้า client fallback ไปServerSentEvents/LongPollingrequest ต้นทางจบไปแล้ว (กระทบOnDisconnectedAsyncเต็มๆ — ดู M-03)- degrade เงียบ —
if (activeCompanyId is not null)แปลว่าเมื่อ resolve ไม่ได้ user จะ ไม่ได้ company push ตลอด lifetime ของ connection โดยไม่มี log ✅ ทางที่ deterministic กว่า (verify แล้วว่าทำได้):InjectUserClaimsre-add identity เดิมกลับเข้า principal (RedisUserInfoMiddleware.cs:233-234) →oidยังอ่านได้จากContext.UserและIOptions<RedisUserInfoOptions>ถูก register จริง (SecurityExtensions.cs:91) → อ่าน Redis ตรงในOnConnectedAsync(~15 บรรทัด, key{KeyPrefix}:user:info:{oid}) ตัด dependencyHttpContext.Itemsทิ้งทั้งชั้น ⚠️ ทางนี้แก้เฉพาะ class ของ bug เรื่อง lifetime/null — ไม่แก้ B3 (stale cache 24 ชม.) ซึ่งยังต้องพึ่ง UserService อยู่
6. READ path — จุดที่ยากสุด (merge 2 แหล่ง)
ทุก query ต้องรวม personal (เดิม) + company active
⚠️ [REVIEW H-03 — สเปกขัดแย้งกับตัวเอง] §6.4
MarkReadAsyncมี containment check ถูกต้องแล้ว (!userCompanyIds.Contains(noti.CompanyId) → return false) = สเปกรู้อยู่ว่าต้องมี — แต่ §6.1 / §6.2 / §6.5 ไม่มีเลย ทั้งสามอันกรองด้วยCompanyId == @activeCompanyIdล้วน วันนี้ยัง exploit ไม่ได้เพราะ controller derive ค่าเอง แต่ guard ไม่ได้อยู่ในโค้ดที่ไหนเลย → caller ที่สอง (ASB consumer / admin screen /?companyId=ที่เพิ่มเพื่อทำ company switching ตาม §1.3 หรือ §7) กลายเป็น cross-tenant IDOR โดยไม่ต้องแก้ repo แม้บรรทัดเดียว แก้: guard ตัวเดียวที่ Application layer (EnsureMemberOf(companyId, userInfo)) เรียกจาก ทั้ง 5 path (count / list / mark-read / mark-all / detail) และ repo ห้ามรับcompanyIdที่ไม่ผ่าน guard นี้ → §13.2⚠️ [REVIEW H-08 + M-08] อีก 2 จุดที่ต้องเขียนลงสเปกให้ชัด:
- ไม่มีใคร cross-check ว่า
DefaultCompanyIdอยู่ในCompaniesจริง และ §6.4 ใช้Companiesขณะที่ §6.1/6.2/6.5 ใช้DefaultCompanyId→ สองเซ็ตนี้ขัดกันได้. ต้อง resolve เป็นCompanies.First(c => c.CompanyId == DefaultCompanyId && c.Status == "Active")?.CompanyIdและถ้า resolve ไม่ได้ = ถือว่า ไม่มีบริษัท (0 row) ห้ามถือว่า unfilteredif (activeCompanyId is null) return personal;ใน §6.1 เป็น pattern ที่ถูก ✅ แต่ §9 เขียน test ว่า “user ไม่มีบริษัท → ระบบไม่พัง” ซึ่งอ่อนเกินไป: implementer ที่ “handle null” ด้วยWHERE (@activeCompanyId IS NULL OR n."CompanyId" = @activeCompanyId)(pattern optional-filter ที่คนทำกันปกติ) จะ คืนประกาศของทุกบริษัท ให้ user ที่ไม่มีบริษัท — และ test ตามที่เขียนไว้ ผ่าน. ต้องเขียน test เป็น assertion:company part = 0 row เป๊ะ
6.1 Unread count รวม
public async Task<int> GetCombinedUnreadAsync(Guid userId, Guid? activeCompanyId, CancellationToken ct)
{
var personal = await _inAppRepo.GetUnreadCountAsync(userId, ct); // เดิม
if (activeCompanyId is null) return personal;
var company = await _companyRepo.GetUnreadCountAsync(userId, activeCompanyId.Value, ct);
return personal + company;
}
// CompanyNotificationRepository
public async Task<int> GetUnreadCountAsync(Guid userId, Guid companyId, CancellationToken ct)
=> await _db.CompanyNotifications
.Where(n => n.CompanyId == companyId)
.Where(n => !_db.CompanyNotificationReads
.Any(r => r.CompanyNotificationId == n.Id && r.UserId == userId)) // ยังไม่อ่าน
.CountAsync(ct);
เทียบ SQL:
SELECT COUNT(*) FROM company_notifications n
WHERE n."CompanyId" = @companyId
AND NOT EXISTS (SELECT 1 FROM company_notification_reads r
WHERE r."CompanyNotificationId" = n."Id" AND r."UserId" = @userId);
6.2 List กระดิ่ง (union + paginate)
ต้อง union 2 ตารางแล้ว sort + paginate รวมกัน — แนะนำ SQL UNION (in-memory merge จะพังเรื่อง paging)
โปรเจกต์ project ทั้งสองแหล่งให้เป็น shape เดียวกัน:
SELECT "Id", 'Personal' AS source, "Title", "Subtitle", "NotificationType",
"ActionLink", NULL AS "AppCode", "IsRead", "CreatedAt"
FROM in_app_notifications WHERE "RecipientUserId" = @userId
UNION ALL
SELECT n."Id", 'Company' AS source, n."Title", n."Subtitle", n."NotificationType",
n."ActionLink", n."AppCode",
(r."CompanyNotificationId" IS NOT NULL) AS "IsRead", n."CreatedAt"
FROM company_notifications n
LEFT JOIN company_notification_reads r
ON r."CompanyNotificationId" = n."Id" AND r."UserId" = @userId
WHERE n."CompanyId" = @activeCompanyId
ORDER BY "CreatedAt" DESC
LIMIT @size OFFSET @offset;
- ใช้ EF raw SQL (
FromSqlInterpolated) หรือ Dapper สำหรับ query นี้ source+Idใช้ตอน mark-read (รู้ว่าไปตารางไหน) — ส่งกลับใน DTO ด้วย
🔴 [REVIEW B5 — query นี้มี correctness bug 6 จุด ใช้ตามที่เขียนไม่ได้] เวอร์ชันที่แก้แล้ว → §13.4
# bug ทำไมพัง 1 FromSqlบนDbSet<T>ต้องคืน column ครบตามTshape ของ union นี้ไม่ตรงกับ InAppNotificationและไม่ตรงกับCompanyNotification→ runtimeInvalidOperationException: The required column 'X' was not present. ต้องประกาศ keyless read-model (HasNoKey()) แล้ว_db.Set<NotificationListRow>().FromSqlInterpolated(...)
หมายเหตุ: repo นี้ ไม่มี raw SQL ที่ไหนเลย (grepFromSql|ExecuteSql|SqlQueryในsrc= 0) และ ไม่มี Dapper ใน csproj → “หรือ Dapper” = เพิ่ม dependency ใหม่ + ต้องส่งGetDbTransaction()เองไม่งั้นหลุด transaction ⇒ อย่าใช้ Dapper2 ไม่ project ScopeเลยNotificationItemDtofield 6 คือstring Scope(non-null) และเป็นคนละความหมายกับSourceที่ §6.3 เพิ่ม → ยุบรวมกัน = filterscopeเดิมพัง + broadcast row กลายเป็น'Personal'. ต้อง project ทั้งคู่: personal →"Scope"(column จริง) +'Personal' AS "Source"; company →'Company' AS "Scope"+'Company' AS "Source"3 filter isRead/notificationType/scopeไม่ถูก push เข้าทั้ง 2 branchของเดิมรองรับ 3 filter นี้ ( InAppNotificationRepository.cs:38-45); ถ้ากรองหลังLIMITใน C# → page ได้ item น้อยกว่าsize+TotalCountผิด.isReadฝั่ง company ไม่ใช่ column → ต้องเขียน(r."CompanyNotificationId" IS NOT NULL) = @isRead4 ไม่คำนวณ TotalCountNotificationListResponse(Items, **TotalCount**, UnreadCount, Page, Size)บังคับ → ถ้าไม่มีต้องส่ง 0 = pagination FE พัง5 ORDER BY "CreatedAt" DESCไม่มี tiebreaker — และ tie เป็นเรื่องปกติ ไม่ใช่ corner caseAuditableEntityInterceptor.cs:45คำนวณvar now = DateTime.UtcNowครั้งเดียวต่อ SaveChanges → broadcast ที่ใช้AddRangeAsyncได้CreatedAtเท่ากันเป๊ะทุก row → PG ไม่รับประกัน order ของ tie ⇒ row ซ้ำ/หายข้ามหน้า. ต้องORDER BY "CreatedAt" DESC, "Id" DESC6 alias ไม่ quote repo นี้ไม่มี snake_case naming convention → column เป็น PascalCase quoted จริง; AS source(ไม่ quote) จะถูก fold เป็น lowercase แล้ว EF map ไม่เจอ. ต้องAS "Source"และระบุ type ให้NULLด้วย (NULL::varchar(50))เพิ่มอีก 3 ข้อที่ต้องรู้:
- H-04:
AppCodeต้องกรองที่ server (AND (n."AppCode" IS NULL OR n."AppCode" = @appCode)) ไม่ใช่ปล่อยให้ client ตัด — ไม่งั้น server page 20 row แล้ว client ตัดเหลือ 3 และTotalCount/UnreadCountนับข้าม app = เลขกระดิ่งไม่ตรงกับ list ตลอด- M-07 injection:
GetNotificationsQuery.Scopeไม่มี validator rule (GetNotificationsQueryValidator.csvalidate แค่ UserId/Page/Size/NotificationType) = free string จาก[FromQuery]→ ถ้า concat ลง SQL คือช่องโหว่. บังคับFromSqlInterpolated/DbParameterทุกค่ารวมLIMIT/OFFSET+ whitelistScopeใน validator- L-02: raw SQL ข้าม
AuditableEntityInterceptor(เป็นSaveChangesInterceptor) — ยอมรับได้เพราะ receipt ไม่มี audit field แต่ต้องรู้ไว้ว่าทุก raw/ExecuteUpdateบนcompany_notificationsในอนาคตจะข้าม audit เงียบๆ (precedent:MarkAllAsReadAsyncเดิม setUpdatedAtมือ แต่ ไม่เคย setUpdatedBy)
6.3 DTO เพิ่ม Source + AppCode
src/Notification03.Application/Features/Notification/DTOs/NotificationItemDto.cs:
public sealed record NotificationItemDto(
Guid Id, string Title, string Subtitle, string NotificationType, string? ActionLink,
string Scope, bool IsRead, DateTime CreatedAt,
string? AppCode = null, // ← client กรอง app
string Source = "Personal"); // ← "Personal" | "Company" (ใช้ตอน mark-read)
✅ ข้อดีที่สเปกทำถูก: field ใหม่เป็น positional-at-end + มี default →
GetNotificationsHandler.cs:22-30ที่ construct DTO อยู่เดิม ยัง compile ได้ ไม่ต้องแก้พร้อมกัน⚠️ [REVIEW H-05 — vocabulary collision] แต่ผลลัพธ์คือมี 2 field ที่ใช้คำว่า
"Personal"พร้อมกัน:Scope∈ {Personal,Broadcast} (ของเดิม,InAppNotificationService.cs:49,99) และSource∈ {Personal,Company} (ใหม่) — consumer switch on ค่าพวกนี้ ⇒ shared vocabulary change = medium risk ขั้นต่ำ ไม่ใช่ additive เฉยๆ แนะนำ: เหลือ discriminator ตัวเดียว — ขยายScopeด้วยค่า"Company"แทนการเพิ่มมิติที่สอง (ถ้าเลือกทางนี้ §6.2 project'Company' AS "Scope"ก็จบ ไม่ต้องมีSourceเลย) ถ้ายืนยันจะเก็บ 2 field ต้องเขียนใน §8 ว่า FE ต้องอ่าน field ไหนตอนไหน⚠️ [REVIEW H-06]
NotificationListResponse.UnreadCount—GetNotificationsHandler.cs:20ยังเรียกrepository.GetUnreadCountAsync(userId)= personal only → list มี company item แต่เลข unread ไม่นับ = กระดิ่งกับ list ขัดกัน. ต้องเปลี่ยนเป็นGetCombinedUnreadAsyncและเพิ่มข้อนี้ใน §9 (checklist พูดถึงแต่ union ไม่พูดถึง field นี้) ⚠️ นี่คือ behavioral break ที่ FE เห็น: ความหมายของUnreadCountเปลี่ยนจาก personal → combined
6.4 Mark read (แยก path ตาม source)
// MarkNotificationReadHandler — ลอง personal ก่อน ไม่เจอค่อย company
public async Task<Result<MarkReadResponse>> Handle(MarkNotificationReadCommand req, CancellationToken ct)
{
var personal = await _inAppRepo.GetForUpdateAsync(req.Id, req.UserId, ct);
if (personal is not null)
{
personal.MarkRead();
await _uow.SaveChangesAsync(ct);
}
else
{
// company: verify เป็นประกาศของบริษัทที่ user สังกัด แล้ว insert receipt (idempotent)
var ok = await _companyRepo.MarkReadAsync(req.Id, req.UserId, req.UserCompanyIds, ct);
if (!ok) return Result.Failure<MarkReadResponse>(Error.NotFound("Notification.NotFound", ...));
}
var unread = await _unreadService.GetCombinedUnreadAsync(req.UserId, req.ActiveCompanyId, ct);
return Result.Success(new MarkReadResponse(true, unread));
}
// CompanyNotificationRepository.MarkReadAsync
public async Task<bool> MarkReadAsync(Guid notiId, Guid userId, IReadOnlyList<Guid> userCompanyIds, CancellationToken ct)
{
var noti = await _db.CompanyNotifications.FirstOrDefaultAsync(n => n.Id == notiId, ct);
if (noti is null || !userCompanyIds.Contains(noti.CompanyId)) return false; // ← กันอ่านของบริษัทที่ไม่สังกัด
if (await _db.CompanyNotificationReads.AnyAsync(r => r.CompanyNotificationId == notiId && r.UserId == userId, ct))
return true; // อ่านแล้ว — idempotent
_db.CompanyNotificationReads.Add(CompanyNotificationRead.Create(notiId, userId));
await _db.SaveChangesAsync(ct);
return true;
}
✅ จุดที่สเปกทำถูก: containment check
!userCompanyIds.Contains(noti.CompanyId)ถูกต้อง และ idempotent-by-design ก็ถูกเจตนา🔴 [REVIEW B2 — double-click ทำให้ได้ HTTP 500 ไม่ใช่ 200] interleaving จริง:
- request A และ B ผ่าน
AnyAsyncทั้งคู่ (ยังไม่มี receipt)- ทั้งคู่
Add+SaveChanges→ คนที่สองได้23505: duplicate key value violates constraint "PK_company_notification_reads"MarkNotificationReadCommandเป็นICommand→TransactionBehaviorเปิด ambient txn ค้างอยู่ (Backend_Package/src/Behaviors/TransactionBehavior.cs:49) → 23505 อาจ ทำให้ txn abort ทั้งก้อน → บรรทัดถัดไป (GetCombinedUnreadAsync) ตายด้วย25P02: current transaction is aborted...(ขึ้นกับ savepoint behavior ของ EF Core 10 = ยังไม่ verify — กระทบ severity ไม่กระทบทางแก้)- ไม่มี retry —
EnableRetryOnFailureถูกปิดโดยเจตนา (DependencyInjection.cs:64-67) แก้: ทิ้ง pre-check ใช้ upsert ตรงๆ (correct ทั้งกรณีมี/ไม่มี ambient txn —ExecuteSqlInterpolatedAsyncenlist ใน EF transaction เดิมอัตโนมัติ):await _db.Database.ExecuteSqlInterpolatedAsync( $"""INSERT INTO company_notification_reads ("CompanyNotificationId","UserId","ReadAt") VALUES ({notiId},{userId},{DateTime.UtcNow}) ON CONFLICT ("CompanyNotificationId","UserId") DO NOTHING""", ct);⚠️ หมายเหตุ:
catch (PostgresException 23505)ใช้แทนไม่ได้ ที่นี่ เพราะ txn abort ไปแล้วตอน catch — catch ใช้ได้เฉพาะเมื่อไม่มี ambient txn⚠️ [REVIEW H-12] repo เรียก
_db.SaveChangesAsync(ct)เอง — ไม่ใช่ premature commit (EF ใช้ ambient txn ของTransactionBehavior) แต่ผิด 3 ข้อ: (1) แตก UoW contract,SaveChangesถูกเรียก 2 รอบ (repo + behavior) (2)EnableRetryOnFailureปิด → transient (40001/57P01/connection reset) ไม่มี retry (3) 23505 ที่จุดนี้ = poison ambient txn. ของเดิมInAppNotificationRepository.cs:20,26ก็ทำแบบนี้ = precedent ที่ผิดอยู่แล้ว — ของใหม่ห้ามทำซ้ำ (ปล่อยให้IUnitOfWork.SaveChangesAsyncที่ handler จัดการ ตามMarkNotificationReadHandler.cs:24)⚠️ [REVIEW M-04 + M-01] สองข้อเล็กแต่ต้องแก้:
- scoping ไม่สม่ำเสมอ — list scope =
DefaultCompanyIdแต่ mark-read gate =userCompanyIds→ user mark-read ประกาศของบริษัทที่ไม่ active ได้ (ไม่ leak data แต่ count/list ไม่ตรงกับที่กด) ⇒ ตัดสินให้ตรงกัน- “ลอง personal ก่อน ไม่เจอค่อย company” = query ทิ้ง 1 ครั้งทุกครั้งที่กด company; มี
Sourceใน DTO อยู่แล้ว → ส่งเข้า command แล้ว route ตรง ไม่ต้อง probe
6.5 Mark all read
- personal:
MarkAllAsReadAsyncเดิม - company: bulk insert receipt สำหรับประกาศบริษัท active ที่ยังไม่มี receipt
INSERT INTO company_notification_reads ("CompanyNotificationId","UserId","ReadAt")
SELECT n."Id", @userId, now() FROM company_notifications n
WHERE n."CompanyId" = @activeCompanyId
AND NOT EXISTS (SELECT 1 FROM company_notification_reads r
WHERE r."CompanyNotificationId" = n."Id" AND r."UserId" = @userId);
🔴 [REVIEW B2]
INSERT..SELECT..WHERE NOT EXISTSไม่พอ — เปิด 2 tab กด mark-all พร้อมกัน:NOT EXISTSของ txn B มองไม่เห็น row ที่ txn A insert ค้างไว้ (READ COMMITTED) → B insert ซ้ำ →23505→ 500 (ไม่มี retry). ต่อท้ายON CONFLICT ("CompanyNotificationId","UserId") DO NOTHING⚠️ [REVIEW H-07 + M-14] อีก 3 ข้อในคำสั่งนี้:
- INSERT ไม่มีเพดาน — คนเข้าใหม่ที่บริษัทมีประวัติ 5,000 ประกาศ = คลิกเดียว insert 5,000 row ใน request เดียว (+ ถ้ามี FK จะถือ
FOR KEY SHAREบน parent ทุก row)now()ผิด clock —now()= transaction start time และเป็นคนละ clock กับDateTime.UtcNowที่ใช้ทุกที่ในโค้ด ⇒ ส่ง@nowเข้ามาแทนReadAtต้องเป็นKind=Utcไม่งั้น Npgsql throwCannot write DateTime with Kind=Local to PostgreSQL type 'timestamp with time zone'
6.6 Controller — ต้องส่ง “my companies” + active เข้า handler
src/Notification01.API/Controllers/v1/NotificationsController.cs อ่านจาก HttpContext.GetUserInfo():
var info = HttpContext.GetUserInfo();
var activeCompanyId = info?.DefaultCompanyId;
var myCompanyIds = info?.Companies?.Select(c => c.Id).ToList() ?? [];
// ส่งเข้า query/command
🔴 [REVIEW M-05] snippet นี้ compile ไม่ผ่าน —
UserCompanyInfoมีCompanyIdไม่ใช่Id(Backend_Package/src/Middleware/RedisUserInfo/UserInfoForRedis.cs:118) ⇒ สัญญาณว่าสเปกเขียนโดยไม่ได้เปิด type จริง และปฏิกิริยาปกติต่อ “อันนี้ compile ไม่ผ่าน” คือไปหา company list จากที่อื่น (request field / ยิง UserService ใหม่) ซึ่งอันตรายกว่าเดิม✅ เวอร์ชันที่ถูก (รวม
Statusfilter ตาม H-09 และ cross-check ตาม H-08):var info = HttpContext.GetUserInfo(); var myCompanyIds = info?.Companies .Where(c => c.Status == "Active") .Select(c => c.CompanyId).ToList() ?? []; var activeCompanyId = info?.Companies .FirstOrDefault(c => c.CompanyId == info.DefaultCompanyId && c.Status == "Active") ?.CompanyId; // resolve ไม่ได้ = ไม่มีบริษัท (0 row) ห้ามแปลว่า unfiltered🔴 [REVIEW H-13 — mass assignment] เอกสารบอกให้ส่ง company id เป็น parameter เข้า query/command ⇒ ความปลอดภัยทั้ง surface ขึ้นอยู่กับว่า controller bind ค่าอย่างไร
- ปลอดภัย (ตรงกับโค้ดเดิม): controller construct record เอง —
new GetNotificationsQuery(GetCurrentUserId(), page, size, ...), identity มาจากICurrentUserService.UserId, รับจาก route/query แค่ scalar (NotificationsController.cs:41,51,62,73,83)- ไม่ปลอดภัย: (1)
[FromBody] MarkNotificationReadCommandหรือ bind record ใดที่มีUserId/ActiveCompanyId/UserCompanyIds→ over-posting ให้ client เลือก tenant เอง (2) เพิ่ม[FromQuery] Guid? companyId“เพื่อทำ company switching” — ซึ่งทั้ง §1.3 option และ §7 ผลักไปทางนั้น- validator ช่วยไม่ได้:
MarkNotificationReadCommandValidatorเป็นNotEmpty()บน Id/UserId = ตรวจ shape ล้วน ไม่มี authorization ⇒ ต้องเขียนเป็นกฎในสเปก: company id derive ใน controller จากHttpContext.GetUserInfo()เท่านั้น ห้าม model-bind ห้ามรับจาก query/body/header
7. 🔄 สลับบริษัท → ต้อง reconnect (คิดเผื่อตามที่สั่ง)
7.1 ปัญหา
connection join company-{oldDefault} ตอน connect → พอ user สลับบริษัท (setDefaultCompany) :
- group membership ยังเป็นบริษัทเก่า (ตั้งตอน connect ไม่อัปเดตเอง)
- กระดิ่ง (scope active company) ยังโชว์ของบริษัทเก่า
- live push ของบริษัทใหม่ → ไม่ได้รับ (ไม่ได้อยู่ group ใหม่)
7.2 Flow ที่ต้องทำ (frontend)
user กดสลับบริษัท
│
▼
1. userService.setDefaultCompany(newId) ──► UserService อัปเดต default
│ + ⚠️ ต้อง invalidate RedisUserInfo cache (§7.3)
▼ (สำเร็จ)
2. realtimeService.disconnect('notify') ──► ออกจาก group บริษัทเก่า
▼
3. realtimeService.connect('notify') ──► OnConnectedAsync อ่าน DefaultCompanyId ใหม่
│ → join company-{newId}
│ → ส่ง UnreadCountUpdated ใหม่
▼
4. reload notification list (REST) ──► กระดิ่งโชว์ประกาศบริษัทใหม่
โค้ด (ต่อยอด Frontend_HostAppSuperApp/apps/shell/src/app/components/panels/corporate-panel/corporate-panel.state.ts):
setDefaultCompany(company: Company): Observable<unknown> {
return this.userService.setDefaultCompany(company.id).pipe(
// หลังสลับสำเร็จ → รี realtime + reload
switchMap(async () => {
await this.realtimeService.disconnect('notify');
await this.realtimeService.connect('notify'); // join group ใหม่ + count ใหม่
await this.notificationService.reloadUnreadCount();
await this.notificationService.reloadList();
}),
);
}
7.3 ⚠️ Risk สำคัญ: RedisUserInfo cache ต้องอัปเดตก่อน reconnect
OnConnectedAsync อ่าน DefaultCompanyId จาก RedisUserInfo (cache) ถ้า cache ยังเป็นค่าเก่าตอน reconnect → join group บริษัทเก่า อีก = flow พัง
ต้องมั่นใจว่า: setDefaultCompany ที่ UserService invalidate/refresh Redis cache ของ user นั้นทันที (หรือ NotificationService bypass cache ตอน hub connect)
- 👉 action: คุยทีม UserService ยืนยันว่า
setDefaultCompanyเคลียร์ RedisUserInfo cache - ทดสอบ: สลับบริษัท → reconnect →
whoami-redis(DiagnosticsController.cs:105) ต้องคืนDefaultCompanyIdใหม่
🔴 [REVIEW B3 — ตัวเลขจริงคือ 24 ชั่วโมง และประเด็นนี้ใหญ่กว่าที่ §7.3 ประเมินไว้มาก]
ตัวเลขที่ verify แล้ว — และต้องอ่าน provenance ให้ถูก (แก้หลัง audit):
appsettings.Development.json:99→"CacheTtlSeconds": 86400(= 24 ชม.) และEnableFallback: true(:95) ⚠️ 86400 นี้ ไม่ใช่ TTL ที่ UserService เขียน blob — grep แล้วพบว่าถูกใช้ ที่เดียวเท่านั้น คือRedisUserInfoMiddleware.WarmRedisCacheAsync(:173StringSetAsync(key, json, TimeSpan.FromSeconds(_options.CacheTtlSeconds))) และ doc comment ของ option เขียนตรงๆ ว่า “TTL สำหรับ SET Redis เมื่อ warm cache จาก HTTP fallback” (RedisUserInfoOptions.cs:70-74) ⇒ นี่คือ TTL ที่ NotificationService ประทับเองตอน re-warm ส่วน TTL/invalidation ที่ UserService ใช้เขียน blob = UNVERIFIED (Backend_UserServiceไม่อยู่ในขอบเขตที่รีวิวนี้เปิด) และนั่นคือคำถาม T0-2 ตัวจริงแต่กลไกจริงแรงกว่าที่เขียนไว้ตอนแรก ไม่ใช่เบากว่า:
EnableFallback: true+ re-warm ที่ ไม่มี invalidation hook ⇒ แม้ UserService invalidate ถูกต้องทุกครั้ง ทุกครั้งที่ cache miss แล้ว fallback สำเร็จ NotificationService จะประทับหน้าต่าง 24 ชม. ใหม่นับจากวินาทีนั้นเอง ⇒ B3 ยังเป็น blocker เต็มตัว ⇒ §7.4 ที่เขียนว่า “gap สั้นๆ ยอมรับได้” ยัง ไม่ตรงกับความจริง — order of magnitude คือชั่วโมง/วัน ไม่ใช่วินาที⚠️ ค่าต่อ env ไม่เท่ากัน — ห้ามใช้เลข dev เป็นตัวเลข production:
appsettings.Sit.jsonมี section นี้ (86400) แต่appsettings.Uat.jsonไม่มี sectionRedisUserInfoเลย ⇒ UAT ไม่ได้ค่านี้จากไฟล์ และค่าที่ deploy จริงมาจากBackend_Iac(CI overlay ทับ — ดู L-05) ⇒ ต้องไปอ่าน IaC ต่อ env ก่อนสรุป revocation window จริง§7.3 ถาม event ผิดตัว — เอกสารถามแค่
setDefaultCompanyแต่ event ที่ gate จุดขายคือ membership change:
event ถ้า cache ไม่ invalidate กระทบอะไร setDefaultCompanyreconnect join group บริษัทเก่า + list filter เก่า ≤24 ชม. + mark-read ของบริษัทใหม่ fail (authz ใช้ Companiesจาก blob เดียวกัน)§7 ทั้งหัวข้อ user ถูกเพิ่มเข้าบริษัท (§7.3 ไม่พูดถึงเลย) จุดขายข้อ 3 ของ Plan C ใช้ไม่ได้ — คนเพิ่งเข้าบริษัทไม่เห็นประกาศเก่านาน ≤24 ชม. → test §9 ข้อ 3 fail §1.3, §11 ข้อ 3 user ถูกถอดจากบริษัท (§7.3 ไม่พูดถึงเลย) ยังอ่านประกาศบริษัทนั้นได้ ≤24 ชม. และบน WS ที่ join group ไว้แล้ว ยังได้ live push ไปเรื่อยๆ ไม่มีกำหนด เพราะ §5 set group ครั้งเดียวตอน connect ⚠️ authorization ไม่ใช่ UX นี่คือประเด็นสถาปัตยกรรม ไม่ใช่ TODO: สเปกนี้ยก RedisUserInfo cache (TTL 24 ชม. / best-effort + fallback / ทีมอื่นเป็นเจ้าของ) ขึ้นเป็น authorization boundary เพียงชั้นเดียว ของข้อมูลข้าม tenant โดยไม่มี authoritative server-side membership check ที่ไหนเลย ⇒ §7.3 ปฏิบัติกับ staleness เหมือน UX bug แต่หลังสเปกนี้มันคือ authorization bug
สิ่งที่ต้องมีก่อนเริ่ม (เลือกทางใดทางหนึ่ง):
- invalidation contract ครอบ 3 event (default-change + add-membership + remove-membership) merged ที่ UserService ก่อน — ไม่ใช่ “คุยกัน” แต่เป็น PR ที่ผูกวันที่ได้
- หรือ ให้ read path ตรวจ membership แบบไม่ผ่าน cache (แพงกว่า แต่ตัด dependency)
- หรือ ตัด §7 ทิ้งตาม H-01 (join ทุกบริษัท) → เหลือเฉพาะ event add/remove membership ที่ยังต้องคุย ➕ ทุกทาง: bound WS exposure — re-check membership เป็นระยะ หรือ force disconnect เมื่อ membership เปลี่ยน (อย่าปล่อยให้ fix ตลอด lifetime ของ connection) และ ประกาศ revocation lag ที่ยอมรับได้ ลงเอกสาร
7.4 กันพลาดเพิ่ม
- ระหว่าง disconnect→connect มี gap สั้นๆ ที่ไม่ได้รับ live — ยอมรับได้ (reload list ตอน step 4 ครอบให้)
- ถ้า reconnect fail → ควร retry / แจ้ง user (อย่าค้างสถานะครึ่งๆ)
⚠️ [REVIEW M-02] “1 connection = 1 บริษัท” จริงระดับ connection แต่ไม่จริงระดับ user —
DefaultCompanyIdเป็น server-side global ต่อ user ขณะที่ group membership เป็น ต่อ connection:
- เปิด 2 tab (หรือ desktop + มือถือ) แล้วสลับบริษัทใน tab A → tab B ยังอยู่
company-{เก่า}รับ live push บริษัทเก่า แต่ REST list ของ tab B เป็นบริษัทใหม่ = live กับ list ของหน้าจอเดียวกันไม่ตรงกัน- สลับบริษัทบน desktop → กระดิ่งบนมือถือเปลี่ยนตามแบบเงียบๆ โดย user ไม่ได้สั่ง ⇒ ต้องเลือก: H-01 (join ทุกบริษัท ปัญหาหายทั้งคลาส) หรือ ประกาศชัดว่า active company เป็น global per user แล้ว broadcast event สลับบริษัทไปทุก connection ของ user นั้น
⚠️ [REVIEW M-10] reconnect ส่งแค่
UnreadCountUpdatedไม่มี list catch-up — ช่วง unclean disconnect (client ยังไม่รู้ว่าตาย ~keepalive 15s / timeout 60s) push หายเงียบ → count กับ list ไม่ตรงจนกว่า user reload เอง ⇒ ให้ client reload list ทุกครั้งที่ reconnect ไม่ใช่แค่ตอนสลับบริษัท⚠️ [REVIEW M-03]
OnDisconnectedAsyncไม่ต้องเขียน remove company group เลย — SignalR ถอด connection ออกจากทุก group ให้เองเมื่อ connection จบ; และบล็อกนั้นเป็นจุดเดียวที่อ่านHttpContextหลัง connection จบ (เด่นชัดกับ SSE/LongPolling fallback ที่ request ต้นทางจบไปแล้ว) = ceremony ที่เพิ่มความเสี่ยงสุทธิ (หมายเหตุ:user-{userId}removal ที่มีอยู่เดิมไม่ต้องไปแก้ — ที่ค้านคือการ ขยาย pattern)
8. Backward Compatibility
| ส่วน | กระทบไหม | เงื่อนไข |
|---|---|---|
personal noti เดิม (in_app_notifications) | ❌ ไม่แตะ | ทำงานเหมือนเดิม |
GetUnreadCount / GetNotifications | ⚠️ แก้ | ต้อง บวก company เข้าไป โดย personal ยังทำงานปกติ (user ไม่มีบริษัท → company part = 0) |
| event SignalR | ❌ | reuse Notification / UnreadCountUpdated เดิม |
| client เดิม | ⚠️ | เพิ่ม appCode filter (null = แสดง) + reconnect หลังสลับบริษัท |
| ตารางใหม่ | ❌ additive | ไม่ชนของเดิม |
✅ “additive” = จริง (verify แล้ว) — 2 table ใหม่ว่างเปล่า ⇒ ไม่มี backfill, ไม่มี table rewrite จาก default value, ไม่ต้อง
CREATE INDEX CONCURRENTLY, ไม่ lock table ที่มี traffic,in_app_notificationsไม่ถูกแตะเลย, rollback = drop 2 table และ apply migration ก่อน deploy code ได้ (code เก่าไม่รู้จัก table ใหม่)⚠️ [REVIEW] ตารางนี้ประเมิน 3 ช่องต่ำเกินจริง:
ส่วน เอกสารเขียน จริง GetUnreadCount/GetNotifications⚠️ แก้ ความหมายของ UnreadCountเปลี่ยน (personal → combined) = behavioral break ที่ FE เห็น ไม่ใช่แค่ “บวกเข้าไป” (H-06)event SignalR ❌ ไม่กระทบ reuse ชื่อ event Notificationแต่ payload ต่างกัน (company push ไม่มีunreadCount) → client เดิมกับใหม่แปล payload ชื่อเดียวกันไม่เหมือนกัน ควรระบุให้ชัดว่า client แยกด้วยอะไรclient เดิม ⚠️ client ที่ยังไม่กรอง appCodeจะ โชว์ประกาศของ app ที่ user ไม่ได้ใช้ และเอกสารไม่มี version gate ⇒ ต้องระบุ: กรองที่ server (H-04) หรือ FE ต้อง deploy ก่อนเปิด triggerDTO (ไม่มีในตาราง) เพิ่ม SourceทับScopeเดิม = shared vocabulary change ต้องนับเป็น medium risk (H-05)
9. Checklist งาน
Task 0 (ทำก่อน — พิสูจน์สมมติฐาน)
-
Context.GetHttpContext()?.GetUserInfo()ในOnConnectedAsyncได้ค่าจริง (DefaultCompanyId,Companiesไม่ null) - ยืนยันกับทีม UserService:
setDefaultCompanyinvalidate RedisUserInfo cache (§7.3)
🔴 [REVIEW] Task 0 ตามที่เขียนไว้ยังไม่ใช่ acceptance criteria ที่รันได้ — เวอร์ชันที่รันได้ + ข้อที่ขาด → §14 ข้อที่ต้องเพิ่มเข้า Task 0 (ทั้ง 4 ข้อเป็น gate ไม่ใช่ nice-to-have):
- ตัดสิน trigger/producer ให้ชัด — §9 ยังเป็น “REST endpoint หรือ ASB consumer” ที่ยังไม่เลือก และ ไม่มีที่ไหนในเอกสารตั้งชื่อ upstream service / use case / คนที่ author ประกาศ ⇒ ลงทุน backend 5-8 วันโดยไม่รู้ว่าใครเรียก และถ้าคำตอบคือ “admin author เอง” จะแตะ
Frontend_AdminSuperAppที่ §10 ไม่ได้ list (B4)- invalidation contract ครอบ membership change ไม่ใช่แค่
setDefaultCompany(B3)- ตัดสิน growth bound ของ receipt table — เปลี่ยนทีหลังแพงเพราะต้องแก้ schema (H-07)
- ตอบว่าทำไม
Scope = "Broadcast"ที่มีอยู่แล้วใช้ต่อยอดไม่ได้ (InAppNotificationService.cs:49,99) — เอกสารไม่เคยอธิบายข้อนี้ก่อนจะเพิ่ม 2 table + rewrite read model (M-11)
Backend
- Entity
CompanyNotification+CompanyNotificationRead - EF config 2 ไฟล์ + migration
-
ICompanyNotificationRepository: Add, AnyByExternalMessageId, GetUnreadCount, GetList(union), MarkRead, MarkAllRead -
SendCompanyAsync+IRealTimeNotifier.NotifyCompanyAsync -
GetCombinedUnreadAsync(personal + company) - แก้
GetUnreadCountHandler,GetNotificationsHandler(union),MarkNotificationReadHandler,MarkAllNotificationsReadHandler,GetNotificationDetail(รองรับ company) -
AppHub: join/remove company group + combined count (inject GetUserInfo + unread service) - Controller: ดึง
DefaultCompanyId+Companiesจาก GetUserInfo ส่งเข้า handler - DTO:
AppCode+Source - trigger: REST endpoint หรือ ASB consumer →
SendCompanyAsync
Frontend
- reuse handler
Notification+ กรองappCode(null = แสดง) - กรอง
appCodeในลิสต์ REST - สลับบริษัท → disconnect + connect realtime + reload count/list (§7.2)
- handle reconnect fail
Test
- คนบริษัท active ได้รับ live / คนบริษัทอื่นไม่ได้รับ
- offline ตอน push → connect มาเห็น + เลขถูก
- คนเพิ่งเข้าบริษัทหลัง push → เห็นประกาศเก่า (จุดขายของ C)
- mark-read company → receipt ถูกสร้าง เลขลด
- mark-all → personal + company ครบ
- สลับบริษัท → reconnect → group + count + list เปลี่ยนเป็นบริษัทใหม่
- personal noti เดิมยังทำงาน
- user ไม่มีบริษัท → ระบบไม่พัง (company part = 0)
10. ประเมิน Effort
| ส่วน | ประเมิน |
|---|---|
| Backend (entity/repo/migration/read-model rewrite/hub) | ~5-8 วัน |
| Frontend (filter + reconnect flow) | ~2-3 วัน |
| ประสาน UserService (cache invalidation) | ขึ้นกับทีมอื่น |
read model rewrite (§6) คือส่วนที่กินเวลาสุด — union query + mark-read 2 path + combined count
⚠️ [REVIEW M-12] ตารางนี้นับ repo ไม่ครบและขาด process cost ที่บังคับ
repo ที่แตะจริง ≥ 4:
Backend_NotificationService·Frontend_HostAppSuperApp(§7.2) ·Backend_UserService(invalidation — เป็น blocking prerequisite ไม่ใช่ “ขึ้นกับทีมอื่น”) ·Backend_Iac(ถ้าเพิ่ม config key ใดๆ เช่น kill-switch flag — ดู §17) · +Frontend_AdminSuperAppถ้าคำตอบของ B4 คือ admin author เองงานที่หายจากตาราง:
- MIGRATION branch workflow (บังคับ, priority สูงสุดตาม CLAUDE.md) —
git checkout MIGRATION→ port เฉพาะ entity/EF config →dotnet ef migrations addบน MIGRATION → verifyhas-pending-model-changes= none → merge MIGRATION กลับเข้า feature. branch มีจริงในโปรเจกต์นี้แล้ว (.git/refs/heads/MIGRATION) — หมายเหตุ: precedent เรื่อง lineage เพี้ยนจากการ author ผิด branch เป็นของ UserService ไม่ใช่ service นี้ (นอกขอบเขตรีวิว) แต่กฎยังบังคับเหมือนกัน- งาน trigger-side (ตัวที่ §9 list ไว้เองแต่ไม่มีในตาราง effort)
DbSet2 ตัว + keyless read-model ในAuthDbContext+AddScoped<ICompanyNotificationRepository>ใน DI (ไม่มีทั้งคู่ตอนนี้ → compile/DI error)GetNotificationDetailcompany branch (§9 list ไว้ แต่ §6 ไม่เคย spec — ดู H-11)- E2E ต้องเป็น headed browser จริง ห้าม seed (กฎโปรเจกต์) → ต้นทุนมากกว่า integration test
- deploy ordering: BE ก่อน FE และ FE ต้องพร้อมก่อนเปิด trigger
⇒ ประเมินใหม่: 5-8 วันคือ backend เฉพาะกรณีที่ B1-B5 ถูกแก้ในสเปกก่อน ถ้าเริ่มตามที่เขียนอยู่ตอนนี้ ต้นทุน rework จะกินเวลาไปมากกว่านั้น
11. TL;DR
- 1 row ต่อประกาศ (ไม่ fan-out) + read-receipt table → กระดิ่งเต็มโดยไม่พึ่ง UserService reverse-lookup
- live = company group (join จาก
DefaultCompanyId), count/history = query DB ตาม “บริษัทฉัน” - unread คิดตอนอ่าน → คนเพิ่งเข้าบริษัทเห็นย้อนหลัง
- สลับบริษัท = disconnect + reconnect + reload (และ UserService ต้องเคลียร์ cache ก่อน — §7.3)
- read model ต้องเขียนใหม่ (union 2 แหล่ง) — ใช้
IsReadเดิมตรงๆ ไม่ได้
⚠️ [REVIEW] ข้อ 3 เป็นจุดขายของ Plan C แต่ต้องอ่านคู่กับ 2 ข้อนี้:
- ยังไม่ทำงานจริงถ้าไม่แก้ B3 — membership มาจาก cache TTL 24 ชม. ⇒ “เห็นย้อนหลัง” ช้าได้ถึง 1 วัน
- ไม่ใช่ upside ล้วน — คนเข้าใหม่เห็นประกาศ ทั้งหมดที่เกิดก่อนเขาเข้าบริษัท = data-exposure decision ที่ธุรกิจต้องเซ็นรับ ไม่ใช่ feature ที่ตัดสินในเอกสาร technical (ดู H-14) + unread badge ของเขาจะเท่ากับจำนวนประกาศทั้งประวัติ (H-07)
📋 ผลรีวิว (เพิ่มโดยการรีวิวเทียบโค้ดจริง — 30/07/2026)
ขอบเขตรีวิว: เอกสารนี้ vs Backend_NotificationService @ 75df653 (branch development) + Backend_Package (SupApp_util_lib)
verdict: replan — สถาปัตยกรรมถูกทาง แต่ยังไม่ควรเริ่ม implement จนกว่า B1-B5 ถูกแก้ในเอกสาร
ระดับความมั่นใจของแต่ละข้อ — อ่านก่อนใช้: ทุก citation ที่มี
file:lineถูกอ่านจากโค้ดจริงและถูก audit ไล่ตรวจซ้ำอีกรอบ แต่ไม่ใช่ทุกข้อสรุปในเอกสารนี้จะ verify ได้จาก 2 repo ข้างบน — ข้อที่ตัดสินจากนอกขอบเขตถูกทำ label ไว้ชัดเจน:
- UNVERIFIED (นอกขอบเขต): พฤติกรรม invalidation ของ
Backend_UserService(B3) · path/gate ของBackend_Iacเช่นcheck-config-parity.pyและค่า config ต่อ env (§16, L-05) · precedentusvc-application-pdf-no-owner-check(H-11) และ precedent MIGRATION lineage (M-12) — ทั้งสองเป็นเรื่องของ UserService ไม่ใช่ service นี้ · Npgsql default (Max Auto Prepare=0) เป็น library default ไม่ใช่ค่าที่อ่านจาก config- ขึ้นกับ runtime behavior ที่โค้ดไม่ตอบ: chain
23505 → txn abort → 25P02ใน B2 ขึ้นกับ savepoint behavior ของ EF Core 10 ⇒ กระทบ severity ของ B2 ไม่กระทบ ทางแก้ (ON CONFLICT DO NOTHINGถูกอยู่ดี)- โค้ด/SQL ใน §13 เป็นร่าง reference ที่ยังไม่ผ่าน compile/run — ใช้เป็นแบบ ไม่ใช่ patch ที่ paste ได้เลย
12. Review findings — risk register
12.0 สิ่งที่สเปกทำถูกแล้ว — verified กับโค้ดจริง
อย่ารื้อของพวกนี้ตอน replan (ทุกข้อ verify กับโค้ดแล้ว):
| ✅ | หลักฐาน |
|---|---|
| SignalR Redis backplane พร้อมแล้ว → company group ทำงานข้าม pod ได้จริง ไม่ต้องทำอะไรเพิ่ม | DependencyInjection.cs:98-119 — dedicated multiplexer แยกจาก key-ops + ChannelPrefix แยกต่อ env |
UseRedisUserInfo() อยู่ก่อน MapHub จริง ตามที่ §5 สมมติ | MiddlewarePipelineExtensions.cs:109 vs :135 |
| เจตนา “persist ก่อน push” ถูก — push ที่ fail ถูก swallow แล้ว degrade อย่างสุภาพเพราะ DB เป็น source of truth ⚠️ แต่ ordering จริงขึ้นกับว่ามี ambient transaction หรือไม่ — ต้องตัดสินตาม C-01 | §4.2 order + SignalRNotifier.cs:26 + TransactionBehavior.cs:49 |
| shared-row + receipt anti-join เป็น shape ที่ถูก สำหรับ “คนเข้าใหม่เห็นย้อนหลัง” และ index-friendly | — |
2 table ใหม่ additive จริง — ไม่ backfill, ไม่ rewrite, ไม่ต้อง CONCURRENTLY, ไม่ lock table ที่มี traffic, rollback = drop 2 table | table ว่างเปล่า, in_app_notifications ไม่ถูกแตะ |
| DTO เพิ่ม field เป็น positional-at-end + default → handler เดิมยัง compile | GetNotificationsHandler.cs:22-30 |
| §6.4 มี containment check ถูกต้อง (สเปกรู้ว่าต้องมี) | §6.4 !userCompanyIds.Contains(...) |
| client join group เองตามอำเภอใจ ไม่ได้ — ไม่มี hub method ไหนรับชื่อ group จาก client | AppHub.cs:38 มี method เดียว CheckAppStatus(string[]?) ที่ filter cached list เท่านั้น |
if (activeCompanyId is null) return personal; เป็น pattern ที่ถูก (short-circuit ใน C# ไม่ใช่ optional SQL predicate) | §6.1 |
EF !_db.CompanyNotificationReads.Any(...) แปลเป็น correlated NOT EXISTS ได้จริง ไม่มี client-eval risk (EF Core ≥3 throw แทน silent client-eval) และ AsNoTracking ไม่จำเป็นกับ CountAsync | ตรงกับ GetUnreadCountAsync เดิมที่ก็ไม่ใส่ |
index เสริม (UserId, CompanyNotificationId) ไม่ redundant กับ PK — PK leading column ผิดด้านสำหรับ predicate r."UserId" = @userId; index นี้ทำให้ planner ใช้ anti-join ที่ scan เฉพาะ receipt ของ user นี้ (index-only scan ได้) | เก็บไว้ |
entity declaration ถูก — Entity base ไม่ define Id (มีแค่ domain events) → ประกาศ Id เองถูกต้อง ตรงกับ InAppNotification.cs:8; IAuditableEntity = 4 member เป๊ะ | Backend_Package/src/Abstractions/Entities/Entity.cs |
12.1 Risk register — 🔴 Blocker
| ID | ความเสี่ยง | กลไกที่ทำให้ระเบิด | โอกาส | blast radius | ตรวจเจอได้อย่างไร | ทางแก้ | เจ้าของ |
|---|---|---|---|---|---|---|---|
| B1 | idempotency key เป็น global ExternalMessageId (§4.2) | upstream event เดียว fan-out หลายบริษัทใช้ EventId เดียวกัน (precedent จริง: WorkflowMakerDispatcher.cs:34 ยิง SendPersonalAsync วน recipients ด้วย externalMessageId ตัวเดียว) → บริษัทที่ 2..N ถูก skip | สูง — เกิดทันทีที่มีประกาศส่งหลายบริษัท | ประกาศ หายเงียบ ไม่มี error ผู้ส่งได้ success | count row ต่อ ExternalMessageId หลังยิง 1 event ไป 2 บริษัท → ต้องได้ 2 | key = (ExternalMessageId, CompanyId) + partial unique index + log ระดับ Information ตอน suppress | BE |
| B2 | check-then-insert 3 จุด (§4.2, §6.4, §6.5) ไม่มี unique constraint / ON CONFLICT | (a) ASB at-least-once + AbandonMessage on transient (FlowStatusNotificationConsumer.cs:129) → 2 consumer เห็น EXISTS=false ทั้งคู่ (READ COMMITTED) → insert ซ้ำ (b) double-click / 2 tab → 23505 → ambient txn ของ TransactionBehavior abort → คำสั่งถัดไปตายด้วย 25P02 → 500 และ ไม่มี retry (EnableRetryOnFailure ปิดโดยเจตนา DependencyInjection.cs:64-67) | สูง — redelivery/double-click เป็นเรื่องปกติ | ประกาศซ้ำในกระดิ่ง + HTTP 500 ตอน mark-read/mark-all | ยิง 2 request ขนาน → ต้องได้ 200 ทั้งคู่, receipt 1 row, ไม่มี 23505/25P02 ใน log | INSERT ... ON CONFLICT DO NOTHING ทุกจุด + unique constraint จริง (ห้ามใช้ catch 23505 เพราะ txn abort แล้ว) | BE |
| B3 | membership cache = authorization boundary เพียงชั้นเดียว โดยที่ NotificationService เอง re-warm blob ด้วย TTL 24 ชม. และไม่มี invalidation hook (RedisUserInfoMiddleware.cs:173 + EnableFallback: true) | §7.3 ถามแต่ setDefaultCompany — ไม่เคยถาม add/remove membership ซึ่งเป็น event ที่ gate ทั้งจุดขายและ authz; แม้ UserService invalidate ถูก การ re-warm ก็ประทับหน้าต่างใหม่ทุกครั้งที่ cache miss; §5 set group ครั้งเดียวตอน connect → WS ที่เปิดค้างได้ push ต่อ ไม่มีกำหนด หลังถูกถอดสิทธิ์ | สูง — re-warm mechanism verified ในโค้ด; พฤติกรรม invalidation ฝั่ง UserService = UNVERIFIED (นอกขอบเขตรีวิว) | ① คนถูกถอดยังอ่านได้ตามหน้าต่าง re-warm = authz bug ② จุดขายข้อ 3 ใช้ไม่ได้ ③ สลับบริษัทแล้ว join group เก่า ⚠️ ตัวเลขจริงต่อ env ต้องอ่านจาก Backend_Iac (Uat ไม่มี section นี้ในไฟล์ repo) | whoami-redis (DiagnosticsController.cs:105) ก่อน/หลังเปลี่ยน membership → CachedAtUtc + Companies ต้องเปลี่ยนทันที และต้องยังเปลี่ยนหลังจงใจทำ cache miss (ทดสอบ re-warm) | invalidation contract ครอบ 3 event merged ก่อน + ปิดช่อง re-warm (invalidate ต้องชนะ fallback) / หรือ read path ไม่ผ่าน cache / หรือตัด §7 ตาม H-01 + bound WS exposure + ประกาศ revocation lag | UserService (blocking) + BE |
| B4 | trigger ยังไม่ตัดสิน (§9 “REST หรือ ASB”) และ ไม่มี authz model เลย | ถ้าลอก test-push/test-broadcast (NotificationsController.cs:88-110 = [Authorize] ล้วน ไม่มี role/API key/company check) → user ที่ login คนไหนก็ได้ POST ประกาศด้วย companyId + Title + ActionLink อะไรก็ได้ เข้าพนักงานทั้งบริษัท | สูง ถ้าไม่ระบุใน spec (implementer จะลอกไฟล์ข้างๆ) | ประกาศทางการปลอม + phishing link ระดับองค์กร (จับคู่กับ M-08 = account takeover) | เรียก trigger ด้วย end-user token ล้วน → ต้อง 401/403 และไม่มี row เกิด | ASB consumer (ไม่มี public surface) หรือ [AllowAnonymous]+[RequireApiKey] ตาม NotificationBatchController.cs:27-28 + กฎว่า caller ใดยิง companyId ใดได้ (API key พิสูจน์ service identity ไม่ใช่ authority over company) | BE + Owner ตัดสิน |
| B5 | §6.2 union query — correctness bug 6 จุด | ดูตารางใน §6.2: FromSql shape mismatch (runtime exception) / ไม่ project Scope / filter ไม่ push เข้า 2 branch / ไม่มี TotalCount / ไม่มี tiebreaker (และ AuditableEntityInterceptor.cs:45 ใช้ now เดียวต่อ SaveChanges ⇒ CreatedAt เท่ากันเป็นเรื่องปกติ) / alias ไม่ quote | สูงมาก — bug 1 ทำให้ตายทันทีตอนรัน | list พังหรือคืนค่าผิด เงียบ (row ซ้ำ/หายข้ามหน้า, TotalCount ผิด → pagination FE พัง) | insert broadcast 50 row ใน SaveChanges เดียว → ไล่ page 1-3 → ต้องไม่มี Id ซ้ำ/หาย, TotalCount คงที่ | ใช้ keyless read-model + query ที่แก้แล้วใน §13.4; ห้ามเพิ่ม Dapper | BE |
12.2 Risk register — 🟠 High
| ID | ความเสี่ยง | กลไก / หลักฐาน | ทางแก้ |
|---|---|---|---|
| H-01 | scope decision “active company only” (§1.3) เป็นตัวสร้าง blocker ของตัวเอง | §7 reconnect flow + §7.3 cross-team dependency + FE repo เพิ่ม — ทั้งหมดมีอยู่เพราะ pin live delivery ไว้ที่ active-only เท่านั้น | join group ทุกบริษัทที่สังกัด (ปกติ 1-3) + filter list IN Companies → §7 หายทั้งหัวข้อ; ถ้ายังต้องการ scope ให้ทำเป็น hub method SwitchCompany(companyId) ที่ validate กับ Companies ใน cache (ใช้ได้แม้ DefaultCompanyId stale เพราะสลับระหว่างบริษัทที่อยู่ใน blob แล้ว) |
| H-02 | Task 0 assumption GetHttpContext()?.GetUserInfo() load-bearing เกินกว่าที่ “⚠️ ต้องพิสูจน์” รับไหว | พิสูจน์บน localhost ไม่มีความหมาย (FallbackEndpoint = cluster DNS :96, FallbackApiKey ว่างในไฟล์ :97 ⇒ cache miss ซ่อมตัวเองไม่ได้บนเครื่อง dev); HttpContext.Items ไม่ใช่สัญญาที่ปลอดภัยกับ long-lived connection; degrade เงียบ ไม่มี log | Task 0 ที่รันได้ → §14; ทางเลือกที่ deterministic = อ่าน Redis ตรงใน hub ด้วย oid จาก Context.User (survive principal swap ที่ RedisUserInfoMiddleware.cs:233-234) + IOptions<RedisUserInfoOptions> (register แล้ว SecurityExtensions.cs:91) |
| H-03 | containment guard มีใน §6.4 แต่ไม่มีใน §6.1/§6.2/§6.5 | repo method รับ companyId ดิบ → caller ที่สอง (ASB / admin / ?companyId=) กลายเป็น cross-tenant IDOR โดยไม่ต้องแก้ repo | guard ตัวเดียวใช้ทั้ง 5 path → §13.2 |
| H-04 | AppCode ให้ client กรอง (§2.1, §9 FE) | server ส่งทุก row ทางสาย → คนไม่มีสิทธิ์ app นั้นเห็นใน DevTools; TotalCount/UnreadCount นับข้าม app → เลขกระดิ่งไม่ตรงกับ list ตลอด | กรองที่ server ทั้ง REST list + unread count และ push แยกด้วย group company-{id}-app-{code}; FE filter เป็น cosmetic แต่ต้องหาแหล่ง entitlement ก่อน — blob มีแค่ UserAppInfo.AppId ไม่มี AppCode (UserInfoForRedis.cs:144) ⇒ mapping AppId<->AppCode เป็น open question ใน Task 0 |
| H-05 | Source ทับ Scope = vocabulary collision | ทั้งคู่มีค่า "Personal"; consumer switch on ค่านี้ ⇒ shared-vocabulary change = medium risk ขั้นต่ำ | เหลือ discriminator เดียว: ขยาย Scope ด้วย "Company" |
| H-06 | NotificationListResponse.UnreadCount ยัง personal-only | GetNotificationsHandler.cs:20 เรียก GetUnreadCountAsync(userId) → list มี company item แต่เลขไม่นับ | เปลี่ยนเป็น GetCombinedUnreadAsync + เพิ่มใน §9 checklist |
| H-07 | receipt table ไม่มี growth bound (ตัดสินก่อนสร้าง table) | ของที่อ่านแล้วโต O(users × notifications) = เท่ากับ fan-out; คนเข้าใหม่ badge = ประวัติทั้งหมด; mark-all = INSERT N row ไม่มีเพดานใน 1 request (+ FK FOR KEY SHARE ทุก row) | watermark cursor → §13.3 หรืออย่างน้อย retention window |
| H-08 | DefaultCompanyId ไม่ถูก cross-check กับ Companies และ §6.4 ใช้ Companies ขณะ §6.1/2/5 ใช้ DefaultCompanyId → 2 เซ็ตขัดกันได้ | blob ที่ stale/inconsistent ทำให้ทุก active-company path ทำงานบนบริษัทที่ user ไม่ได้สังกัด | resolve เป็น Companies.First(c => c.CompanyId == DefaultCompanyId && c.Status == "Active"); resolve ไม่ได้ = 0 row ห้าม unfiltered |
| H-09 | UserCompanyInfo.Status ไม่ถูกกรองที่ไหนเลย | membership ที่ถูก revoke/suspend ยังอ่านได้ (คูณกับ TTL 24 ชม.) | Where(c => c.Status == "Active") ทุกจุดที่ build company id set |
| H-10 | index (CompanyId, CreatedAt) ไม่พอ | tiebreaker Id ไม่อยู่ใน index → planner Sort ทั้ง partition ทุกครั้งที่ page | (CompanyId, CreatedAt DESC, Id DESC) → §13.1 |
| H-11 | company GetNotificationDetail อยู่ใน §9 checklist แต่ §6 ไม่เคย spec | company_notifications ไม่มี user column ⇒ implementation ที่ “ทำให้ compile ผ่าน” คือ GetByIdAsync(id) → ใครมี GUID อ่าน Detail/SpecialDetail (เนื้อหาเต็มที่ตั้งใจไม่ใส่ใน SignalR payload) ของบริษัทอื่นได้; id หาได้ไม่ยาก เพราะ notification.Id ถูก broadcast เข้า group ทั้งบริษัท. องค์กรนี้มี precedent (usvc-application-pdf-no-owner-check — UserService, นอกขอบเขตรีวิวนี้) | spec company branch: WHERE n."Id"=@id AND n."CompanyId" = ANY(@companyIds) + LEFT JOIN receipt (NotificationDetailDto ต้องมี IsRead+ReadAt) + คืน 404 ไม่ใช่ 403 (กัน existence oracle) |
| H-12 | repo เรียก _db.SaveChangesAsync() เอง (§6.4) | ไม่ใช่ premature commit แต่: แตก UoW contract (SaveChanges 2 รอบ) + ไม่มี retry + 23505 = poison ambient txn. ของเดิม InAppNotificationRepository.cs:20,26 ก็ผิดอยู่แล้ว | ของใหม่ห้ามทำซ้ำ — ปล่อย IUnitOfWork.SaveChangesAsync ที่ handler (MarkNotificationReadHandler.cs:24) |
| H-13 | company id เป็น command/query parameter (§6.6) | ปลอดภัยเฉพาะเมื่อ controller construct record เอง; [FromBody] record ที่มี UserId/company ids = over-posting ให้ client เลือก tenant; validator เป็น shape-only ไม่ช่วย | เขียนเป็นกฎในสเปก: derive จาก GetUserInfo() เท่านั้น ห้าม model-bind + review rule |
| H-14 | ”คนเข้าใหม่เห็นประกาศเก่า” ถูกนำเสนอเป็น upside ล้วน | เป็น data-exposure decision — คนเข้าใหม่เห็นทุกอย่างที่เกิดก่อนเขาเข้าบริษัท | ยกเป็นคำถามธุรกิจให้เซ็นรับ + พิจารณา cutoff (เห็นย้อนหลังไม่เกิน N วัน / เฉพาะหลังวันเข้าบริษัท) |
| C-01 | ordering ของ commit-vs-push ไม่ถูกกำหนด และ §4.2 กับ H-12 ขัดกันเอง | path ที่ไม่มี ambient txn (ASB เรียก service ตรง) → repo commit เองก่อน push = ถูก; path ที่ผ่าน MediatR ICommand → TransactionBehavior commit หลัง handler ⇒ ถ้าทำตาม H-12 push จะเกิดก่อน commit → rollback แล้ว client เห็นประกาศที่ไม่มีใน DB | เลือกหนึ่งทาง: ให้ write path นี้ commit เองนอก TransactionBehavior หรือ ทำตาม H-12 แล้วย้าย push ไปหลัง commit ด้วย outbox / post-commit hook |
| H-15 | GetForUpdateAsync ที่ §6.4 พึ่ง ไม่ได้ lock row จริง — ชื่อ method โกหก | InAppNotificationRepository.cs:72-77 เป็น FirstOrDefaultAsync แบบ tracked เฉยๆ ไม่มี FOR UPDATE ⇒ ยืนยันว่า race ใน B2 เกิดได้จริงทั้ง personal และ company path ไม่มีอะไรกันอยู่ | ไม่ต้องพึ่งชื่อ method — ใช้ ON CONFLICT ตาม B2 (และถ้าอยาก lock จริงต้อง FOR UPDATE explicit) |
12.3 Risk register — 🟡 Medium / 🟢 Low
| ID | ประเด็น | ทางแก้สั้น |
|---|---|---|
| M-01 | mark-read “ลอง personal ก่อนไม่เจอค่อย company” = query ทิ้ง 1 ครั้งทุกครั้งที่กด company | ส่ง Source จาก DTO เข้า command แล้ว route ตรง |
| M-02 | multi-tab / multi-device: live group (per connection) ไม่ sync กับ DefaultCompanyId (per user) | H-01 หรือ broadcast switch event ไปทุก connection ของ user |
| M-03 | OnDisconnectedAsync remove company group = ceremony ไม่จำเป็น + อ่าน HttpContext หลัง connection จบ | ไม่ต้องเขียน — SignalR ถอด group เองเมื่อ connection จบ |
| M-04 | scoping ไม่สม่ำเสมอ: list ใช้ DefaultCompanyId / mark-read gate ใช้ userCompanyIds | ตัดสินให้ตรงกัน |
| M-05 | §6.6 c.Id compile ไม่ผ่าน (จริงคือ CompanyId) | แก้ snippet (ดู §6.6) |
| M-06 | SourceService มาจาก caller แต่ถูก label ว่า audit = spoofable + ชนกับ dedup namespace | derive จาก authenticated caller identity |
| M-07 | §6.2 raw SQL + GetNotificationsQuery.Scope ไม่มี validator rule (free string จาก [FromQuery]) | FromSqlInterpolated/DbParameter ทุกค่า รวม LIMIT/OFFSET + whitelist Scope/sort key ใน validator (ตามที่ NotificationType ทำอยู่แล้ว) + คง cap Size 1..100 |
| M-08 | ActionLink/Title/Detail ไม่มี validation | allowlist ActionLink (relative หรือ https + host จาก config); ประกาศ Title/Subtitle/Detail เป็น text-only + max length ⇒ ตัวนี้คือสิ่งที่ทำให้ B4 ยกระดับจาก spam เป็น account takeover |
| M-09 | null activeCompanyId ถ้าเขียนเป็น optional SQL predicate (@x IS NULL OR ...) จะคืน ทุกบริษัท — และ test §9 ตามที่เขียนไว้ ผ่าน | เขียน test เป็น assertion company part = 0 row เป๊ะ + short-circuit ใน C# |
| M-10 | reconnect ส่งแค่ count ไม่มี list catch-up | reload list ทุกครั้งที่ reconnect |
| M-11 | ไม่เคยอธิบายว่าทำไม Scope="Broadcast" เดิมใช้ต่อยอดไม่ได้ | ตอบใน Task 0 |
| M-12 | §10 นับ repo ไม่ครบ + ขาด MIGRATION workflow / DbSet+DI / trigger work / E2E headed / deploy order | ดู annotation §10 |
| M-13 | ไม่มี FK จริงระหว่าง 2 ตาราง (เอกสารเขียนแค่ comment) | HasOne<CompanyNotification>().WithMany().HasForeignKey(...).OnDelete(Cascade) — ทราบผลข้างเคียง: insert receipt ถือ FOR KEY SHARE บน parent (สำคัญตอน mark-all N row) |
| M-14 | now() (= txn start time, คนละ clock กับ DateTime.UtcNow) และ ReadAt ที่ Kind != Utc | ส่ง @now เข้ามา; DateTime.UtcNow เท่านั้น ไม่งั้น Npgsql throw Cannot write DateTime with Kind=Local... |
| L-01 | logging drift: catch ที่ swallow push failure ไม่มี metric; เสี่ยงมีคนใส่ payload.Title/PII ตอน debug | log เฉพาะ CompanyId + NotificationId + SourceService; เพิ่ม counter ตอน push fail |
| L-02 | raw SQL ข้าม AuditableEntityInterceptor เงียบ (precedent: MarkAllAsReadAsync เดิม set UpdatedAt แต่ไม่เคย set UpdatedBy) | รับได้เพราะ receipt ไม่มี audit field — แต่ต้องรู้ไว้ |
| L-03 | CompanyNotification มี UpdatedAt/UpdatedBy แต่ไม่มี mutator เลย = column ตาย | ตัดเฉพาะ 2 member UpdatedAt/UpdatedBy — ห้ามตัด IAuditableEntity เพราะ interceptor filter ด้วย Entries<IAuditableEntity>() (AuditableEntityInterceptor.cs:47) ⇒ ตัด interface แล้วไม่มีใคร set CreatedBy ที่ §13.1 ประกาศ .IsRequired() = NOT NULL violation และ B5#5 ก็พึ่งกลไกนี้ |
| L-04 | migration pattern: ไฟล์อยู่ Migrations/ แต่ namespace เป็น ...Persistence.Migrations (mismatch มีอยู่เดิม); DateTime เดิมเป็น timestamptz ทุกตัว; index ใช้ descending: array + filter: | ตามของเดิม; unique+partial index ต้องอยู่ใน migration เดียวกับ CreateTable |
| L-05 | PgBouncer (transaction pooling) | Npgsql default Max Auto Prepare=0 + multiplexing off (library default ไม่ใช่ค่าที่อ่านจาก config นี้) ⇒ raw SQL ปลอดภัย; ห้าม pg_advisory_lock (session-scoped) ใช้ pg_advisory_xact_lock; ห้ามรัน migration ผ่าน PgBouncer ต่อ PG ตรง; ค่า PgBouncer:Enabled จริงอยู่ IaC (CI overlay ทับ) |
| L-06 | relative link ในเอกสารนี้พังทั้งหมด (./SignalR-Realtime.md, ../src/...) เพราะเขียนไว้ให้อยู่ข้าง repo แต่ไฟล์อยู่ Downloads | ย้ายไฟล์ไปข้าง repo หรือแก้เป็น absolute |
13. ของที่แก้ให้แล้ว (ร่าง reference — ยังไม่ผ่าน compile/run)
13.1 EF config ที่แก้แล้ว — index + constraint set
// company_notifications
builder.ToTable("company_notifications");
builder.HasKey(n => n.Id);
builder.Property(n => n.AppCode).HasMaxLength(50);
builder.Property(n => n.Title).HasMaxLength(300).IsRequired();
builder.Property(n => n.Subtitle).HasColumnType("text").IsRequired();
builder.Property(n => n.SpecialDetail).HasColumnType("text");
builder.Property(n => n.Detail).HasColumnType("text");
builder.Property(n => n.NotificationType).HasMaxLength(20).IsRequired().HasDefaultValue("Info");
builder.Property(n => n.ActionLink).HasMaxLength(1000);
builder.Property(n => n.SourceService).HasMaxLength(100);
builder.Property(n => n.ExternalMessageId).HasMaxLength(255);
builder.Property(n => n.CreatedAt).IsRequired();
builder.Property(n => n.CreatedBy).HasMaxLength(256).IsRequired();
builder.HasIndex(n => new { n.CompanyId, n.CreatedAt, n.Id })
.HasDatabaseName("IX_company_notifications_company_created")
.IsDescending(false, true, true);
// B1 + B2 — ตัวที่ทำให้ idempotency เป็นจริง ไม่ใช่ advisory
builder.HasIndex(n => new { n.ExternalMessageId, n.CompanyId })
.HasDatabaseName("IX_company_notifications_external_message_dedup")
.IsUnique()
.HasFilter("\"ExternalMessageId\" IS NOT NULL");
// company_notification_reads
builder.ToTable("company_notification_reads");
builder.HasKey(r => new { r.CompanyNotificationId, r.UserId });
builder.Property(r => r.ReadAt).IsRequired();
builder.HasIndex(r => new { r.UserId, r.CompanyNotificationId })
.HasDatabaseName("IX_company_notification_reads_user_notification");
builder.HasOne<CompanyNotification>()
.WithMany()
.HasForeignKey(r => r.CompanyNotificationId)
.OnDelete(DeleteBehavior.Cascade);
สรุป constraint set ที่ต้องมี:
- PK
company_notifications(Id) IX_company_notifications_company_created (CompanyId, CreatedAt DESC, Id DESC)— list + tiebreakerIX_company_notifications_external_message_dedup UNIQUE (ExternalMessageId, CompanyId) WHERE ExternalMessageId IS NOT NULL- PK
company_notification_reads(CompanyNotificationId, UserId)— anti-join probe + upsert target IX_company_notification_reads_user_notification (UserId, CompanyNotificationId)— ไม่ redundant- FK
company_notification_reads.CompanyNotificationId → company_notifications(Id) ON DELETE CASCADE - (ถ้ารับ §13.3)
company_notification_read_cursorsPK(UserId, CompanyId)
13.2 Membership guard — จุดเดียวใช้ทุก path
// Notification03.Application — ใช้ทั้ง 5 path: count / list / mark-read / mark-all / detail
// repo method ห้ามรับ companyId ที่ไม่ผ่านตัวนี้
public static Guid? ResolveActiveCompany(UserInfoForRedis? info) =>
info?.Companies
.FirstOrDefault(c => c.CompanyId == info.DefaultCompanyId && c.Status == "Active")
?.CompanyId;
public static IReadOnlyList<Guid> ResolveMemberCompanies(UserInfoForRedis? info) =>
info?.Companies.Where(c => c.Status == "Active").Select(c => c.CompanyId).ToList() ?? [];
กฎที่ต้องเขียนลงสเปก: company id derive ใน controller จาก HttpContext.GetUserInfo() เท่านั้น — ห้าม model-bind, ห้ามรับจาก query/body/header, และ command/query record ที่มี UserId/company ids ต้อง construct ไม่ใช่ [FromBody]
13.3 ทางเลือกที่ bound growth ได้ — watermark cursor
แก้ H-07 (receipt โตเท่า fan-out + mark-all insert ไม่มีเพดาน) โดยไม่ทิ้ง shared-row model:
| ตาราง | คอลัมน์ | PK |
|---|---|---|
company_notification_read_cursors | UserId, CompanyId, ReadUpToCreatedAt | (UserId, CompanyId) |
- mark-all =
UPDATE 1 row(O(1) ไม่ใช่ O(N)) - unread =
CreatedAt > cursor AND NOT EXISTS receipt - receipt เก็บเฉพาะที่อ่านเดี่ยวหลัง cursor ⇒ ขนาดถูก bound
- ถ้ายังไม่รับทางนี้ อย่างน้อยต้องมี retention window — และต้องตัดสิน ก่อน สร้าง table
ต้องรู้ก่อนตัดสิน (ยังไม่มีข้อมูล): ประกาศต่อบริษัทต่อเดือน + จำนวน user ต่อบริษัท
13.4 Union query ที่แก้แล้ว
-- ค่าทุกตัวต้องเป็น parameter (FromSqlInterpolated) รวม LIMIT/OFFSET
SELECT * FROM (
SELECT "Id",
"Scope", -- ของจริง: Personal | Broadcast
'Personal' AS "Source",
"Title", "Subtitle", "NotificationType", "ActionLink",
NULL::varchar(50) AS "AppCode",
"IsRead", "CreatedAt"
FROM in_app_notifications
WHERE "RecipientUserId" = @userId
AND (@notificationType IS NULL OR "NotificationType" = @notificationType)
AND (@isRead IS NULL OR "IsRead" = @isRead)
UNION ALL
SELECT n."Id",
'Company' AS "Scope",
'Company' AS "Source",
n."Title", n."Subtitle", n."NotificationType", n."ActionLink",
n."AppCode",
(r."CompanyNotificationId" IS NOT NULL) AS "IsRead",
n."CreatedAt"
FROM company_notifications n
LEFT JOIN company_notification_reads r
ON r."CompanyNotificationId" = n."Id" AND r."UserId" = @userId
WHERE n."CompanyId" = @activeCompanyId -- ผ่าน guard §13.2 มาแล้ว
AND @includeCompany -- scope='Personal' → กรอง row ของ branch นี้ออกทั้งหมด
AND (n."AppCode" IS NULL OR n."AppCode" = ANY(@myAppCodes)) -- H-04 กรองที่ server
AND (@notificationType IS NULL OR n."NotificationType" = @notificationType)
AND (@isRead IS NULL OR (r."CompanyNotificationId" IS NOT NULL) = @isRead)
) t
ORDER BY t."CreatedAt" DESC, t."Id" DESC -- tiebreaker บังคับ (B5 #5)
LIMIT @size OFFSET @offset;
ข้อบังคับที่มาพร้อม query นี้:
- ประกาศ keyless read-model:
modelBuilder.Entity<NotificationListRow>().HasNoKey().ToView(null)แล้วเรียกด้วย_db.Set<NotificationListRow>().FromSqlInterpolated(...)— ห้ามFromSqlบนDbSet<InAppNotification>(shape ไม่ตรง → runtime exception) และ ห้ามเพิ่ม Dapper TotalCount:count(*) OVER ()ใน outer select ก่อน LIMIT ได้ 1 round-trip แต่ ถ้า OFFSET เกินท้ายจะได้ 0 row →TotalCountหาย ⇒ ทางที่ปลอดภัยกว่าและตรง pattern เดิม (InAppNotificationRepository.cs:47แยกCountAsyncอยู่แล้ว) = count แยก 1 query@activeCompanyIdเป็น null → short-circuit ใน C# ห้ามเขียนเป็นIS NULL ORใน SQL (M-09)- keep
Size1..100 cap + whitelistScopeใน validator (M-07)
14. Task 0 ที่รันได้จริง
Task 0 เดิมเป็น “ต้องพิสูจน์” ที่ไม่มีวิธีวัด — เวอร์ชันที่รันได้:
T0-1 — GetUserInfo() ใน OnConnectedAsync (ต้องรันบน dev cluster ไม่ใช่ localhost)
- deploy hub ที่ log
RedisInjected,DefaultCompanyId,Companies.CountตอนOnConnectedAsync(ยังไม่ต้อง join group) - cold cache:
DEL DEV_UserService:user:info:{oid}ก่อน connect → พิสูจน์ว่า fallback ทำงานผ่าน cluster DNS จริง (บนเครื่อง dev ทำไม่ได้ —FallbackEndpointเป็นsvc.cluster.local) - connect ผ่าน APIM dev path จริง ด้วย transport ที่ client ใช้จริง และ บังคับ fallback
LongPolling/ServerSentEventsอีกรอบ — ทั้ง 2 กรณีต้อง non-null - ✅ pass = ทั้ง 4 combination (warm/cold × WS/long-polling) log
DefaultCompanyIdได้ไม่ null - ❌ fail แม้กรณีเดียว → ใช้ทางเลือกอ่าน Redis ตรงใน hub (H-02) ห้ามปล่อยให้ degrade เงียบ — ต้อง log + ส่ง signal ให้ client รู้
T0-2 — cache invalidation ครอบ 3 event (ต้องได้คำตอบเป็น PR ไม่ใช่คำพูด)
ยิง whoami-redis (DiagnosticsController.cs:105) ก่อน/หลังแต่ละ event แล้วดู CachedAtUtc + Companies:
| event | ต้องเห็นอะไร | ถ้าไม่ผ่าน |
|---|---|---|
setDefaultCompany(new) | DefaultCompanyId เปลี่ยนทันที | §7 ทั้งหัวข้อใช้ไม่ได้ |
| เพิ่ม user เข้าบริษัท | Companies มีตัวใหม่ทันที | จุดขายข้อ 3 ใช้ไม่ได้ |
| ถอด user ออกจากบริษัท | Companies หายทันที | authz bug |
T0-3 — trigger/producer (Owner ตัดสิน): ใคร author ประกาศ? service ไหนยิง? REST หรือ ASB? caller ใดยิง companyId ใดได้? ⇒ ถ้าคำตอบคือ admin author เอง ต้องเพิ่ม Frontend_AdminSuperApp เข้า §10
T0-4 — growth bound (H-07) และ T0-5 — ทำไม Scope="Broadcast" เดิมไม่พอ (M-11)
15. Test cases ที่ต้องเพิ่มใน §9
§9 ปัจจุบันไม่มี concurrency / authz / paging test เลย — ทุกข้อล่างนี้ map กับ finding ที่ระบุ:
- B1 ยิง event เดียว (
EventIdเดิม) ไป 2 บริษัท → ต้องได้ 2 row; ยิงซ้ำ → ยังได้ 2 row - B2a 2 process ยิง
SendCompanyAsyncพร้อมกัน (externalMessageId+companyIdเดียวกัน) → 1 row, ไม่มี 23505 ใน log, push SignalR ครั้งเดียว - B2b double-click mark-read (2 request ขนาน) → 200 ทั้งคู่, receipt 1 row, ไม่มี
25P02 - B2c 2 tab กด mark-all พร้อมกัน → 200 ทั้งคู่, unread = 0, ไม่มี duplicate key
- B4 เรียก trigger ด้วย end-user token ล้วน → 401/403, ไม่มี row, ไม่มี push; เรียกด้วย service credential แต่
companyIdที่ไม่มีสิทธิ์ → reject - B5 insert broadcast 50 row ใน SaveChanges เดียว (
CreatedAtเท่ากันทุก row) → ไล่ page 1-3 size 20 → ไม่มี Id ซ้ำ/หาย,TotalCountคงที่ - B5+H-04
?isRead=false¬ificationType=Info&scope=Personal→ ทุก row ตรง filter และTotalCount= จำนวนหลัง filter - H-03/H-11 user บริษัท A เรียก detail / mark-read ด้วย id ของบริษัท B → 404 (ไม่ใช่ 200, ไม่ใช่ 403), body ไม่มี
Title/Detail - H-03 พยายามใส่
companyIdของบริษัท B ผ่านทุก binding surface (query / body / header / JSON field เกิน) → 0 row สำหรับ B, ไม่มี 500 - H-09/B3 ถอด membership /
Status≠ Active → REST ปฏิเสธภายใน lag ที่ประกาศไว้ และ WS ที่เปิดค้างต้องหยุดได้ push - H-08 ตั้ง
DefaultCompanyIdเป็นบริษัทที่ไม่อยู่ในCompanies→ company part = 0 row, ไม่ join group - M-09 user ไม่มีบริษัท → unread = personal เป๊ะ, list = personal row เท่านั้น (assert จำนวน row ไม่ใช่แค่ HTTP 200)
- M-07
?scope=' OR 1=1--+ sort key ที่ไม่รองรับ → validation error, ไม่มี SQL error, ไม่มี row เกิน - M-08
ActionLink = javascript:alert(1)/ external host → reject ที่ server;Titleที่มี HTML → เก็บและคืนแบบ escaped - H-04 สมาชิกบริษัทที่ไม่มีสิทธิ์ app X → ทั้ง SignalR frame และ REST list ต้องไม่มีประกาศของ X
- perf
EXPLAIN (ANALYZE)unread count → Index (Only) Scan บนIX_company_notifications_company_created+ anti-join ใช้ PK หรือIX_..._user_notification(ไม่มี Seq Scan); union page 1 → ไม่มี Sort node บน branch company - migration
dotnet ef migrations has-pending-model-changes= none (บนMIGRATIONและหลัง merge เข้า feature);SELECT pg_typeof("CreatedAt"), pg_typeof("ReadAt")→timestamp with time zoneทั้งคู่
⚠️ E2E ต้องเป็น headed browser จริง กดผ่านหน้าจอทุก step ห้าม seed / ห้ามยิง API สร้างข้อมูล ตามกฎโปรเจกต์
16. ทางเลือก de-risk — staged split
ข้อเสนอ ไม่ใช่การตัดสินใจแทน — ถ้า Owner ต้องการ ship คุณค่าที่เห็นได้ก่อนโดยเลี่ยง blocker ข้ามทีม:
| Phase 1 — additive, reversible | Phase 2 — read-model rewrite | |
|---|---|---|
| ทำอะไร | 2 table ใหม่ + constraint ครบ (§13.1) + SendCompanyAsync + NotifyCompanyAsync + hub join group ทุกบริษัทที่สังกัด (H-01) + list/count filter IN Companies | union query (§6.2) + mark-read 2 path + combined count + active-company scoping + §7 reconnect flow |
| ไม่ต้องมี | §7 ทั้งหัวข้อ · reconnect flow · setDefaultCompany invalidation · FE เหลือแค่ appCode filter | — |
| ยังต้องมี | B1 B2 B4 B5(บางส่วน) + invalidation เฉพาะ add/remove membership | B3 เต็มรูป (default-change invalidation) |
| rollback | drop 2 table | ย้อน read model |
| effort ส่วนที่กินเวลา | น้อย | นี่คือที่ 5-8 วันไปอยู่ (§10 ระบุเองว่า read model rewrite กินเวลาสุด) |
ถ้าเลือกทำทั้งก้อนทีเดียว (ตามเอกสารเดิม) ให้พิจารณา kill-switch flag ต่อ env — ⚠️ แต่ flag = key ใหม่ใน appsettings ⇒ ต้องเพิ่มใน Backend_Iac/config/notification-service/{env}/appsettings.json ครบทุก env ไม่งั้น CI check-config-parity.py จะ fail build (ค่าจริงบน cluster มาจาก IaC ไม่ใช่ repo)
17. Definition of Ready — เช็คก่อนเปิด PR แรก
- B1 dedup key =
(ExternalMessageId, CompanyId)+ partial unique index อยู่ในสเปก - B2 ทุก insert ที่มี dedup semantics เขียนเป็น
ON CONFLICT DO NOTHING(§4.2, §6.4, §6.5) - B3 invalidation contract ครอบ 3 event merged ที่ UserService (หรือเลือกทางที่ไม่พึ่ง cache) + bound WS exposure + ประกาศ revocation lag
- B4 trigger + authz model ตัดสินแล้ว (ใคร author / service ไหน / caller ใดยิง company ใดได้)
- B5 union query แทนด้วย §13.4 + keyless read-model + count แยก
- H-03 membership guard ตัวเดียวใช้ทั้ง 5 path (§13.2) + กฎห้าม model-bind company id
- H-07 ตัดสิน growth bound (watermark หรือ retention) ก่อนสร้าง table
- H-11 company
GetNotificationDetailเขียนลง §6 พร้อม guard + 404 - H-04
AppCodeกรองที่ server ทั้ง list + count + push - H-05 เหลือ discriminator ตัวเดียว
- H-06
UnreadCountเป็น combined + แจ้ง FE ว่าความหมายเปลี่ยน - process migration author บน branch
MIGRATION→ verifyhas-pending-model-changes= none → merge กลับเข้า feature - process ถ้าเพิ่ม config key ใดๆ → update
Backend_Iacครบทุก env - §10 อัปเดต repo list + effort ให้ตรงความจริง (≥4 repo, deploy BE ก่อน FE)