Company Notification — Spec v2 (BE + FE)
สเปกที่ใช้จริง self-contained ครอบคลุมทั้ง backend และ frontend — decision box, prerequisite, data model, authorization guard, read/write path, SignalR, FE constraint, traceability 41 finding และ test charter
อัปเดต: 2026-07-30
Company Notification — Spec v2 (BE + FE)
เอกสารนี้ self-contained — implement จากไฟล์นี้ไฟล์เดียวได้ ไม่ต้องเปิด Plan C เดิม Plan C เดิม + ผลรีวิว = COMPANY_NOTIFICATION_PLANC_REVIEW.md — audit trail เท่านั้น ห้าม implement จากไฟล์นั้น (snippet ในนั้นมี bug ที่พิสูจน์แล้ว) แผนลงมือ = COMPANY_NOTIFICATION_IMPLEMENTATION_PLAN.md
ฐานอ้างอิง: Backend_NotificationService @ 75df653 · Backend_Package (SupApp_util_lib) · Frontend_HostAppSuperApp @ f68f646 · Frontend_SuperAppLibraryUi/libs/util-sdk @ 1.1.0 · Backend_UserService (branch fix/uat)
1. สิ่งที่จะได้ และไม่ได้
ได้: กระดิ่ง/inbox ระดับบริษัท — live push, unread count, ดูย้อนหลัง, offline แล้วกลับมาเห็น, mark-read/mark-all — โดย ไม่ต้องให้ UserService ทำ reverse-lookup ว่าใครอยู่บริษัทไหน
ไม่ได้ (ตั้งใจ): ไม่ใช่ announcement authoring UI · ไม่ใช่ระบบ targeting รายบุคคล · ไม่แตะ personal notification เดิม
หลักการ 3 ข้อ
- Live delivery = company group — connection join
company-{id}ของ ทุกบริษัทที่ user สังกัด ตอน connect (สมาชิก self-register เอง) - Persistence = shared row — 1 row ต่อ 1 ประกาศ (ไม่ fan-out) + ตาราง read-receipt แยก
- Unread = forward lookup — “ประกาศของบริษัทฉันที่ฉันยังไม่อ่าน” โดยเช็ค membership ตอนอ่าน ไม่ใช่ตอน push
2. Decision box — ตัดสินแล้ว / ยังไม่ตัดสิน
2.1 ✅ ตัดสินแล้ว (Owner, 30/07/2026)
D-1 — scope ของกระดิ่ง = ทุกบริษัทที่สังกัด
| เลือก | join group ทุกบริษัทที่ Status == "Active" (ปกติ 1-3 group) + list/count filter CompanyId IN (myCompanies) |
| ปฏิเสธ | active-company-only (= DefaultCompanyId) ตามที่ Plan C เดิม §1.3 เลือกไว้ |
| เหตุผล | active-only เป็นตัว สร้าง blocker ของตัวเอง — มันคือเหตุผลเดียวที่ทำให้ต้องมี reconnect flow ตอนสลับบริษัท, ต้องพึ่ง UserService invalidate setDefaultCompany, และต้องแก้ corporate-panel.state.ts. เลือก all-companies แล้ว ทั้งสามอย่างหายไปพร้อมกัน |
| ใครพลิกได้ | Owner / Business |
| ถ้าพลิกกลับ | §7 reconnect flow ของ Plan C เดิมกลับมาทั้งหัวข้อ + UserService setDefaultCompany invalidation กลายเป็น blocking prerequisite + ต้องแก้ corporate-panel.state.ts โดยห้ามทำ tap()→switchMap() หลุด (ดู §9.5) + ต้อง hand-roll reload เพราะ hook เดิมไม่ match |
D-2 — ประวัติย้อนหลัง = ตั้งแต่วันที่เข้าบริษัท
| เลือก | user เห็นเฉพาะประกาศที่ CreatedAt >= joinedAt ของบริษัทนั้น |
| ปฏิเสธ | เห็นทั้งหมด (จุดขายเดิมของ Plan C) · เห็นย้อนหลัง N วัน |
| เหตุผล | คนเข้าใหม่ไม่ควรเห็นเรื่องที่เกิดก่อนเป็นพนักงาน — เป็น data-exposure decision; และ bound unread badge ไปในตัว |
| ผลข้างเคียงที่ต้องทำ | joinedAt ยังไม่มีใน RedisUserInfo blob → ต้องเพิ่ม (ดู P-1) |
| ถ้าพลิกกลับ | ตัด predicate CreatedAt >= joinedAt ออก แล้ว H-07 (growth bound) กลับมาเป็นปัญหาเต็มตัวทันที |
2.2 ✅ ตัดสินแล้วรอบสอง (Owner, 30/07/2026) — STOP gate เดิมปิดครบ
D-3 — trigger = ASB event จาก service ภายในเท่านั้น
| เลือก | NotificationService consume ASB topic — ไม่มี HTTP endpoint สำหรับสร้างประกาศบริษัทเลย |
| ปฏิเสธ | REST service-to-service (X-API-Key) · admin เขียนเองผ่าน Admin Portal |
| ผลที่ได้ทันที | ปิดช่องโหว่ที่ร้ายแรงที่สุดของแผนเดิม — ไม่มี public surface ⇒ ไม่มีเคส “user ที่ login คนไหนก็ยิงประกาศเข้าทั้งบริษัทได้” และ ไม่ต้องออกแบบกฎ caller→companyId authority เพราะสิทธิ์ถูกคุมที่ ASB topic ไม่ใช่ที่ HTTP |
| ยังต้องระบุ (ไม่ใช่ security gate แล้ว) | ชื่อ topic + service ที่ publish + message contract — ต้องตกลงกับทีมเจ้าของ event; ไม่บล็อกการเขียน consumer แต่บล็อกการ deploy จริง |
| ถ้าพลิกกลับ | ถ้าวันหน้าต้องมี admin authoring → กลับมาเป็น public surface ⇒ ต้องมี admin role + กฎว่า admin คนไหนยิงบริษัทไหนได้ + Frontend_AdminSuperApp เข้ามาเป็น repo ที่ 6 |
D-4 — growth bound = receipt อย่างเดียว พึ่ง D-2 คุม
| เลือก | ไม่เพิ่มตาราง ไม่เพิ่ม retention job — company_notification_reads ตามสเปก §4.2 |
| ปฏิเสธ | receipt + retention window · watermark cursor table |
| เหตุผล | D-2 (CreatedAt >= joinedAt) bound ทั้ง unread badge และจำนวน row ที่ mark-all แตะไปแล้วระดับหนึ่ง — เอาของที่ง่ายที่สุดก่อน |
| ⚠️ trade-off ที่รับไว้แล้ว | ① mark-all ยัง INSERT N row ต่อคลิก (N = ประกาศที่ยังไม่อ่านตั้งแต่วันเข้าบริษัท) ② receipt โต O(users × notifications) ในระยะยาวไม่มีเพดาน ⇒ ต้องเฝ้าขนาดตาราง และถ้าโตเกินคาดค่อยเติม retention ทีหลัง (เติมได้โดยไม่แตะ schema เพราะ FK cascade ลบ receipt ให้เอง) |
| สัญญาณที่ต้องกลับมาทบทวน | จำนวน row ใน company_notification_reads โตเร็วผิดคาด หรือ latency ของ mark-all สูงขึ้น |
D-5 — app scoping = เก็บ AppId Guid? และกรองที่ server
| เลือก | คอลัมน์ AppId uuid NULL (NULL = ทุก app) และ กรองที่ server เทียบกับ UserAppInfo.AppId ใน blob |
| ปฏิเสธ | AppCode string? (blob ไม่มี AppCode ⇒ กรอง server ไม่ได้ ต้องปล่อยให้ client กรอง = H-04) · ไม่มีมิติ app เลย |
| เหตุผล | UserInfoForRedis.Apps[].AppId มีอยู่ใน blob แล้ว ⇒ เลือก AppId แล้ว กรอง server-side ได้ทันทีโดยไม่ต้องหา entitlement source ใหม่ |
| ผลที่ได้ | H-04 ปิด — ไม่มีการส่ง row ที่ user ไม่มีสิทธิ์ทางสาย, unreadCount/TotalCount/list นับตรงกันทั้งหมด, FE ไม่ต้องกรองอะไรเลย |
| ⚠️ ข้อควรระวัง | UserAppInfo.AppId เป็น string? ที่เก็บ GUID ส่วนคอลัมน์เป็น uuid ⇒ ต้อง parse/cast ให้ชัด และ ค่าที่ parse ไม่ได้ต้องถูกทิ้ง ไม่ใช่ถือว่าผ่าน |
ไม่มี STOP gate เหลือแล้ว — สิ่งที่ยังต้องประสานคือชื่อ topic + message contract ของ D-3 ซึ่งเป็นงานตกลงกับทีมอื่น ไม่ใช่การตัดสินใจออกแบบ
3. Prerequisite ที่ต้องเสร็จก่อน (ข้ามไม่ได้)
| ID | สิ่งที่ต้องมี | repo | ทำไม |
|---|---|---|---|
| P-1 | JoinedAt ใน UserCompanyInfo ของ blob | Backend_Package + Backend_UserService | D-2 ต้องใช้ · ข้อมูลมีอยู่แล้ว: UserCompanyMapping : IAuditableEntity มี CreatedAt (UserCompanyMapping.cs:43) และจุด map คือ UsersMapper.cs:240-247 |
| P-2 | membership-change invalidation (add/remove) | Backend_UserService | ไม่มี = คนถูกถอดยังอ่านได้ (authz) และคนเข้าใหม่ไม่เห็น (feature) |
| P-3 | ปิดช่อง re-warm ทับ invalidation | Backend_Package / ops | RedisUserInfoMiddleware.cs:173 ประทับ TTL ใหม่ทุกครั้งที่ fallback สำเร็จ ⇒ invalidate อาจถูกลบล้าง |
| P-4 | ยืนยัน GetUserInfo() ใน OnConnectedAsync ไม่ null บน dev cluster (cold blob) | — | ดู §8.2 — มี fallback design ถ้าไม่ผ่าน |
P-1 รายละเอียด (additive ห้าม bump schema version)
// Backend_Package/src/Middleware/RedisUserInfo/UserInfoForRedis.cs — record UserCompanyInfo
// Additive: optional so old readers ignore it and old cache blobs deserialize to null.
// Do NOT bump CurrentSchemaVersion — the strict != guard would drop blobs for not-yet-upgraded readers.
[JsonPropertyName("joinedAt")]
public DateTime? JoinedAt { get; init; }
// Backend_UserService/src/UserService03.Application/Features/Users/UsersMapper.cs:240-247
.Select(ucm => new UserCompanyInfo
{
CompanyId = ucm.CompanyId,
// ...เดิม...
JoinedAt = ucm.CreatedAt, // ← เพิ่ม
})
⚠️
JoinedAt == null(blob เก่าที่ยังไม่ถูก re-warm) ต้องมีกฎชัด — spec นี้กำหนด:null⇒ ถือว่าไม่จำกัด (เห็นทั้งหมด) เพราะ fail-open ตรงนี้เป็น UX ไม่ใช่ authz (membership ยังถูกเช็คแยกอยู่แล้ว) และ blob จะหายไปเองภายใน TTL. ถ้าธุรกิจรับ fail-open ไม่ได้ → เปลี่ยนเป็นnull ⇒ 0 rowแล้วต้องรอ blob หมุนครบก่อนเปิด feature ⚠️Backend_Packageเปลี่ยน = ต้อง publish package แล้ว bump ที่ service ที่ใช้
4. Data model
4.1 company_notifications — 1 row ต่อ 1 ประกาศ
| คอลัมน์ | ชนิด | หมายเหตุ |
|---|---|---|
| Id | Guid PK | |
| CompanyId | Guid | บริษัทเป้าหมาย |
| AppId | uuid NULL | NULL = ทุก app · กรองที่ server เทียบ blob (D-5) |
| Title | varchar(300) | |
| Subtitle | text | |
| SpecialDetail, Detail | text? | |
| NotificationType | varchar(20) | default Info |
| ActionLink | varchar(1000)? | ต้องผ่าน allowlist (§10.4) |
| SourceService | varchar(100)? | derive จาก authenticated caller ห้ามรับจาก body |
| ExternalMessageId | varchar(255)? | idempotency |
| CreatedAt, CreatedBy | audit |
4.2 company_notification_reads — read receipt
| คอลัมน์ | ชนิด |
|---|---|
| CompanyNotificationId | Guid |
| UserId | Guid |
| ReadAt | timestamptz |
4.3 EF config — index + constraint set (ครบ)
// company_notifications
builder.ToTable("company_notifications");
builder.HasKey(n => n.Id);
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.Property(n => n.AppId); // uuid NULL — NULL = ทุก app
builder.HasIndex(n => new { n.CompanyId, n.CreatedAt, n.Id })
.HasDatabaseName("IX_company_notifications_company_created")
.IsDescending(false, true, true);
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);
เหตุผลของแต่ละตัว
(CompanyId, CreatedAt DESC, Id DESC)— tiebreakerIdต้องอยู่ใน index ไม่งั้น planner Sort ทุกครั้งที่เปิดหน้าถัดไป และ tie เป็นเรื่องปกติ เพราะAuditableEntityInterceptor.cs:45ใช้DateTime.UtcNowครั้งเดียวต่อ SaveChanges แล้ว assign ให้ทุก entity (:52)- UNIQUE
(ExternalMessageId, CompanyId)partial — ตัวที่ทำให้ idempotency เป็นจริง ไม่ใช่แค่ advisory - index
(UserId, CompanyNotificationId)ไม่ redundant กับ PK — PK leading column ผิดด้านสำหรับ predicater.UserId = @userId - FK cascade — กัน orphan receipt; ทราบผลข้างเคียง: insert receipt ถือ
FOR KEY SHAREบน parent (สำคัญตอน mark-all) AppIdไม่ใส่ใน index — predicate เป็นIS NULL OR = ANY(...)ใช้ index prefix ไม่ได้ ปล่อยเป็น filter บน heap
4.4 Entity
public class CompanyNotification : Entity, IAuditableEntity // Entity base ไม่ define Id → ประกาศเอง
{
public Guid Id { get; private set; }
public Guid CompanyId { get; private set; }
public Guid? AppId { get; private set; } // NULL = ทุก app
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(/* ... */) => new() { Id = Guid.NewGuid(), /* ... */ };
}
public class CompanyNotificationRead // POCO + composite key, ไม่ derive Entity
{
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 };
}
⚠️ ห้ามตัด
IAuditableEntityแม้จะไม่มี mutator —AuditableEntityInterceptor.cs:47filter ด้วยEntries<IAuditableEntity>()⇒ ตัด interface = ไม่มีใคร setCreatedByที่ประกาศ.IsRequired()= NOT NULL violation ⚠️ ต้องเพิ่มDbSet2 ตัว + keyless read-model ในAuthDbContextและAddScoped<ICompanyNotificationRepository>ใน DI — ไม่มีทั้งคู่ตอนนี้ ⚠️ Repository interface ต้องอยู่Notification04.Domain/Ports/Persistence/
5. Authorization — จุดเดียวใช้ทุก path
สาระ: membership มาจาก RedisUserInfo blob ซึ่งเป็น TTL cache ของทีมอื่น — สเปกนี้ยกมันเป็น authz boundary จึงต้องมี guard เดียวที่ทุก path เรียก และต้องประกาศ revocation lag
// Notification03.Application — ใช้ทั้ง 5 path: count / list / mark-read / mark-all / detail
public sealed record CompanyScope(Guid CompanyId, DateTime? JoinedAt);
public static IReadOnlyList<CompanyScope> ResolveMemberCompanies(UserInfoForRedis? info) =>
info?.Companies
.Where(c => c.Status == "Active") // H-09 — ห้ามลืม
.Select(c => new CompanyScope(c.CompanyId, c.JoinedAt))
.ToList() ?? [];
กฎบังคับ (เขียนเป็น review rule)
- company id derive ใน controller จาก
HttpContext.GetUserInfo()เท่านั้น — ห้าม model-bind, ห้ามรับจาก query/body/header - command/query record ที่มี
UserIdหรือ company ids ต้อง construct เอง ห้าม[FromBody](validator เป็น shape-only ไม่ใช่ authz —MarkNotificationReadCommandValidatorเป็นแค่NotEmpty()) - repo method ห้ามรับ
companyIdที่ไม่ผ่าน guard นี้ - resolve ไม่ได้ / ไม่มีบริษัท ⇒ 0 row เป๊ะ ห้ามแปลว่า unfiltered และ ห้ามเขียนเป็น optional SQL predicate (
@x IS NULL OR ...) — ต้อง short-circuit ใน C# - revocation lag ที่ประกาศ: เท่ากับอายุ blob ที่เหลือ (worst case = TTL เต็ม) ⇒ ต้องมี P-2 + P-3
- bound WS exposure — group membership ถูก set ตอน connect เท่านั้น ⇒ connection ที่เปิดค้างต้องถูกตัดหรือ re-check เมื่อ membership เปลี่ยน ไม่ใช่ปล่อยตลอด lifetime
6. Write path
// เรียกจาก ASB consumer เท่านั้น (D-3) — ไม่มี HTTP endpoint
public async Task SendCompanyAsync(
Guid companyId, InAppNotificationPayload payload, Guid? appId = null,
string? externalMessageId = null, CancellationToken ct = default)
{
// 1. persist ด้วย upsert — ไม่ใช่ check-then-insert
// dedup key = (ExternalMessageId, CompanyId)
// affected rows = 0 ⇒ duplicate ⇒ ไม่ push
var inserted = await _companyRepo.InsertIfNotDuplicateAsync(notification, ct);
if (!inserted)
{
_logger.LogInformation("[CompanyNotification] duplicate suppressed — CompanyId={Cid} ExternalMessageId={Mid}",
companyId, externalMessageId);
return;
}
// 2. push เข้า company group (ไม่มี unreadCount — group push ใส่เลขรายคนไม่ได้)
await _notifier.NotifyCompanyAsync(companyId.ToString(), "Notification", new { ... }, ct);
}
// repository — upsert ที่ correct ทั้งกรณีมีและไม่มี ambient transaction
var affected = await _db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO company_notifications (...) VALUES (...)
ON CONFLICT ("ExternalMessageId","CompanyId") DO NOTHING
""", ct);
return affected > 0;
ทำไมต้องเป็นแบบนี้
- dedup key ต้องมี
CompanyId— precedent จริงในโค้ดนี้:WorkflowMakerDispatcher.cs:33-34ยิงSendPersonalAsyncวน recipients ด้วยexternalMessageId: e.EventId.ToString()ตัวเดียว ⇒ ถ้า key เป็น global แล้วประกาศเดียวยิงหลายบริษัท บริษัทที่ 2..N จะ หายเงียบ - check-then-insert ใช้ไม่ได้ — ASB เป็น at-least-once + consumer abandon ตอน transient (
FlowStatusNotificationConsumer.cs:129) ⇒ redelivery ปกติ; 2 consumer เห็นEXISTS=false ทั้งคู่ (READ COMMITTED) → insert ซ้ำ - ห้าม
catch (PostgresException 23505)— ถ้ามี ambient transaction จากTransactionBehavior(:49) txn abort ไปแล้วตอน catch และ ไม่มี retry (EnableRetryOnFailureปิดโดยเจตนาDependencyInjection.cs:64-67)
6.1 Commit-vs-push ordering — ต้องเลือกให้ชัด
SendCompanyAsync ถูกเรียกได้ 2 ทางที่มี transaction semantics ต่างกัน:
| path | ambient txn | ผลถ้า repo ไม่ commit เอง |
|---|---|---|
| ASB consumer เรียก service ตรง | ไม่มี | commit เกิดที่ repo → push หลัง commit ✅ |
ผ่าน MediatR ICommand | มี (TransactionBehavior) | commit เกิดหลัง handler return → push ก่อน commit ⇒ rollback แล้ว client เห็นประกาศที่ไม่มีใน DB ❌ |
สเปกนี้กำหนด: write path ของ company notification ไม่วิ่งใต้ TransactionBehavior — ให้ repo commit เอง แล้ว push หลัง commit
ถ้าจำเป็นต้องวิ่งใต้ command: ต้องย้าย push ออกไปหลัง commit ด้วย outbox หรือ post-commit hook ห้ามปล่อยกำกวม
6.2 IRealTimeNotifier.NotifyCompanyAsync
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);
_pushFailureCounter.Add(1); // ห้าม swallow เงียบโดยไม่มี metric
}
}
log ได้เฉพาะ
CompanyId/NotificationId/SourceService— ห้าม log payload หรือ PII
7. Read path
7.1 Unread count (personal + company)
public async Task<int> GetCombinedUnreadAsync(Guid userId, IReadOnlyList<CompanyScope> companies, CancellationToken ct)
{
var personal = await _inAppRepo.GetUnreadCountAsync(userId, ct);
if (companies.Count == 0) return personal; // short-circuit ใน C#
return personal + await _companyRepo.GetUnreadCountAsync(userId, companies, ct);
}
-- membership + joinedAt ส่งเข้ามาเป็นตาราง (companyId, joinedAt) ใช้ pattern เดียวกับ §7.2/§7.5
SELECT COUNT(*)
FROM company_notifications n
JOIN (VALUES /* ({companyId}::uuid, {joinedAt}::timestamptz), ... */) AS m("CompanyId","JoinedAt")
ON m."CompanyId" = n."CompanyId"
AND (m."JoinedAt" IS NULL OR n."CreatedAt" >= m."JoinedAt")
WHERE (n."AppId" IS NULL OR n."AppId" = ANY(@myAppIds)) -- D-5 กรองที่ server
AND NOT EXISTS (SELECT 1 FROM company_notification_reads r
WHERE r."CompanyNotificationId" = n."Id" AND r."UserId" = @userId);
ใช้
JOIN (VALUES ...)แทนINเพราะแต่ละบริษัทมีjoinedAtของตัวเอง — เขียนเป็นINไม่ได้ ทั้ง 3 query (count / list / mark-all) ต้องประกอบ VALUES list ด้วย parameter ทุกช่อง ห้าม string concat EF!_db.CompanyNotificationReads.Any(...)แปลเป็น correlatedNOT EXISTSได้จริง ไม่มี client-eval risk
7.2 List — union 2 แหล่ง
-- ทุกค่าเป็น parameter (FromSqlInterpolated) รวม LIMIT/OFFSET
SELECT * FROM (
SELECT "Id", "Scope", "Title", "Subtitle", "NotificationType", "ActionLink",
NULL::uuid AS "AppId", "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", n."Title", n."Subtitle", n."NotificationType", n."ActionLink",
n."AppId", (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
JOIN (VALUES /* (companyId, joinedAt) ต่อบริษัท */) AS m("CompanyId","JoinedAt")
ON m."CompanyId" = n."CompanyId" AND (m."JoinedAt" IS NULL OR n."CreatedAt" >= m."JoinedAt")
WHERE @includeCompany
AND (n."AppId" IS NULL OR n."AppId" = ANY(@myAppIds)) -- D-5 กรองที่ 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
LIMIT @size OFFSET @offset;
ข้อบังคับ
- ประกาศ keyless read-model
modelBuilder.Entity<NotificationListRow>().HasNoKey().ToView(null)แล้วเรียกด้วย_db.Set<NotificationListRow>().FromSqlInterpolated(...)—FromSqlบนDbSet<InAppNotification>ใช้ไม่ได้ (shape ไม่ตรง → runtimeInvalidOperationException) - ห้ามเพิ่ม Dapper — repo นี้ไม่มี raw SQL ที่ไหนเลยและไม่มี Dapper ใน csproj; ถ้าใส่ต้องส่ง
GetDbTransaction()เองไม่งั้นหลุด transaction TotalCountแยก 1 query (ตรง pattern เดิมInAppNotificationRepository.cs:47) —count(*) OVER ()จะหายเมื่อ OFFSET เกินท้ายORDER BY ... , "Id" DESCบังคับ — ไม่มี tiebreaker = row ซ้ำ/หายข้ามหน้า- quote alias ทุกตัว (
AS "Scope"ไม่ใช่AS scope) — repo นี้ไม่มี snake_case column convention, column เป็น PascalCase quoted จริง; unquoted จะ fold เป็น lowercase แล้ว EF map ไม่เจอ - whitelist
Scope+ sort key ใน validator — วันนี้GetNotificationsQueryValidatorไม่มี rule สำหรับScopeเลย (free string จาก[FromQuery]) - ⚠️ raw SQL ข้าม
AuditableEntityInterceptor(เป็นSaveChangesInterceptor) — รับได้ที่นี่ แต่รู้ไว้
7.3 Contract — ขยาย Scope ไม่เพิ่ม Source
public sealed record NotificationItemDto(
Guid Id, string Title, string Subtitle, string NotificationType, string? ActionLink,
string Scope, // "Personal" | "Broadcast" | "Company" ← ขยายค่าเดิม
bool IsRead, DateTime CreatedAt);
// ไม่มี AppCode/AppId ใน DTO — server กรองให้แล้ว (D-5) FE ไม่ต้องรู้จัก app dimension
เหตุผลที่ไม่เพิ่ม
Source: จะได้ 2 field ที่มีค่า"Personal"พร้อมกัน = vocabulary collision ที่ consumer switch on. และฝั่ง FENotificationApiItem.scopeมีอยู่บน wire แล้ว (notification.model.ts:44-51) แค่ถูก mapper ทิ้ง ⇒ ขยายScopeถูกและถูกกว่าทั้งสองฝั่ง ⚠️UnreadCountเปลี่ยนความหมาย จาก personal-only เป็น combined (GetNotificationsHandler.cs:20ต้องเปลี่ยนไปGetCombinedUnreadAsync) — เป็น behavioral break ที่ FE เห็น
7.4 Mark read — route ตรงด้วย Scope ไม่ probe
// scope == "Company" → company path; อื่น ๆ → personal path เดิม (ไม่ต้องลอง personal ก่อน)
var affected = await _db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO company_notification_reads ("CompanyNotificationId","UserId","ReadAt")
SELECT n."Id", {userId}, {DateTime.UtcNow} FROM company_notifications n
WHERE n."Id" = {notiId} AND n."CompanyId" = ANY({memberCompanyIds})
ON CONFLICT ("CompanyNotificationId","UserId") DO NOTHING
""", ct);
SELECT ... WHERE CompanyId = ANY(...)ทำให้ membership guard อยู่ใน statement เดียวกับ insert — ไม่มีช่อง TOCTOU ห้าม pre-check ด้วยAnyAsync— double-click 2 request ผ่าน check ทั้งคู่ →23505repo ห้ามเรียก_db.SaveChangesAsync()เอง — ปล่อยให้IUnitOfWorkที่ handler (ของเดิมInAppNotificationRepository.cs:20,26ทำแบบนี้อยู่ = precedent ที่ผิด ห้ามทำซ้ำ)GetForUpdateAsyncไม่ได้ lock row จริง แม้ชื่อจะบอกอย่างนั้น (InAppNotificationRepository.cs:72-77เป็นFirstOrDefaultAsyncเฉยๆ) — อย่าพึ่งมันกันแข่ง
7.5 Mark all read
INSERT INTO company_notification_reads ("CompanyNotificationId","UserId","ReadAt")
SELECT n."Id", @userId, @now FROM company_notifications n
JOIN (VALUES /* (companyId, joinedAt) */) AS m("CompanyId","JoinedAt")
ON m."CompanyId" = n."CompanyId" AND (m."JoinedAt" IS NULL OR n."CreatedAt" >= m."JoinedAt")
ON CONFLICT ("CompanyNotificationId","UserId") DO NOTHING;
ON CONFLICTบังคับ — 2 tab กด mark-all พร้อมกันNOT EXISTSมองไม่เห็น row ที่อีก txn ค้างไว้ ส่ง@nowจาก C# ห้ามใช้now()(= txn start time และคนละ clock กับที่อื่น) ·ReadAtต้องKind=Utcไม่งั้น Npgsql throw ⚠️ จำนวน row ที่ insert ถูก bound ด้วย D-2 (>= joinedAt) เท่านั้น — D-4 รับ trade-off แล้วว่ายังเป็นINSERT N rowต่อคลิก ⇒ เฝ้าขนาดตารางไว้
7.6 Detail by id — ต้องมี ไม่ใช่ optional
FE routing บังคับให้ endpoint นี้ถูกเรียก: notification.state.ts:122 route ไป /notification/:id → หน้า detail เรียก getNotificationById (notification.service.ts:77-90) ⇒ ทุกครั้งที่ user กดประกาศบริษัท
SELECT n.*, (r."CompanyNotificationId" IS NOT NULL) AS "IsRead", r."ReadAt"
FROM company_notifications n
LEFT JOIN company_notification_reads r ON r."CompanyNotificationId" = n."Id" AND r."UserId" = @userId
WHERE n."Id" = @id AND n."CompanyId" = ANY(@memberCompanyIds)
AND (@joinedAtForThatCompany IS NULL OR n."CreatedAt" >= @joinedAtForThatCompany);
- ไม่เจอ ⇒ 404 ไม่ใช่ 403 (กัน existence oracle)
- ⚠️ ถ้าไม่ทำ: company notification ทุกอันกดแล้ว 404; ถ้าทำแบบ
GetByIdAsync(id)เฉยๆ: IDOR — id ถูก broadcast เข้า group ทั้งบริษัทอยู่แล้ว
8. SignalR / Hub
8.1 OnConnectedAsync
public override async Task OnConnectedAsync()
{
var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
if (!string.IsNullOrEmpty(userId))
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");
var companies = ResolveMemberCompanies(await ResolveUserInfoAsync());
if (companies.Count == 0)
_logger.LogWarning("[AppHub] no company resolved — ConnectionId={Cid} UserId={Uid}", Context.ConnectionId, userId);
foreach (var c in companies)
await Groups.AddToGroupAsync(Context.ConnectionId, $"company-{c.CompanyId}");
if (Guid.TryParse(userId, out var userGuid))
await Clients.Caller.SendAsync("UnreadCountUpdated",
new { unreadCount = await _unreadService.GetCombinedUnreadAsync(userGuid, companies, Context.ConnectionAborted) },
Context.ConnectionAborted);
await base.OnConnectedAsync();
}
OnDisconnectedAsync — ไม่ต้องเขียน remove company group SignalR ถอด connection ออกจากทุก group ให้เองเมื่อ connection จบ และการอ่าน HttpContext หลัง connection จบเป็นความเสี่ยงฟรี
8.2 การ resolve user info ใน hub — 2 ทาง
ทาง A (ใช้ GetHttpContext()?.GetUserInfo()) | ทาง B (อ่าน Redis ตรง) | |
|---|---|---|
| ทำงานไหม | UseRedisUserInfo() (MiddlewarePipelineExtensions.cs:109) อยู่ก่อน MapHub (:135) ⇒ middleware run จริง | oid รอดใน Context.User เพราะ InjectUserClaims re-add identity เดิม (RedisUserInfoMiddleware.cs:233-234) + IOptions<RedisUserInfoOptions> register แล้ว (SecurityExtensions.cs:91) ⇒ GET {KeyPrefix}:user:info:{oid} |
| ความเสี่ยง | พึ่ง HttpContext.Items ที่ออกแบบมาสำหรับ per-request | ~15 บรรทัด, deterministic |
สเปกนี้เลือก: ทำ P-4 ก่อน → ผ่านใช้ทาง A, ไม่ผ่านใช้ทาง B
✅ ความเสี่ยงเรื่อง long-polling/SSE ไม่มีในแอปนี้ —
app.config.ts:267-278pinSignalRTransport.WebSocketsและsignalr-realtime.client.ts:44-47ทำให้skipNegotiation = true⇒ ไม่มี negotiate, ไม่มี fallback transport ⇒ P-4 เหลือทดสอบแค่ warm/cold blob × WebSocket ⚠️ แต่มีความเสี่ยงที่ใหญ่กว่าที่คาด:app.config.ts:258-261bootstrapawait realtimeAppConnectionService.initialize()(= connect) ก่อนsyncUserInfoAndPerm(...)⇒ hub อ่าน blob ตอน FE ยัง sync ไม่เสร็จ = first-login / cold blob มีโอกาส null จริง ⇒ ห้าม degrade เงียบ ต้อง log + ส่ง signal ให้ client รู้ว่า company scope ยังไม่พร้อม
9. Frontend
ขอบเขต FE ต่างจากที่ Plan C เดิมประเมิน — เดิมเขียนว่า “filter appCode + reconnect” ซึ่ง ผิดทั้งสองอย่าง: การกรอง appCode ย้ายไป server แล้ว (D-5) และ reconnect flow หายไปแล้ว (D-1) · สิ่งที่ต้องทำจริงคือ model/mapper, mark-read routing และ thundering herd ✅ ไม่ต้องแก้
@exim/util-sdkและไม่ต้อง publish lib —state: Signal<RealtimeConnectionState>+subscribe()+connect/disconnectครบ และแอปเข้าถึงconnection.invoke()ตรงได้อยู่แล้ว (realtime-app-connection.service.ts:128-141)
9.1 ⛔ Constraint ที่ห้ามละเมิด (ไม่งั้นต้อง publish lib)
- subscriber เดียวต่อ channel —
signalr-realtime.client.ts:163ทำhandlers.set(channel, handler)⇒ ถ้ามีคนที่ 2subscribe('Notification', ...)ตัวแรกจะถูกทับและตายเงียบหลัง reconnect. company handling ต้องอยู่ใน subscription เดิมของRealtimeAppConnectionServiceห้ามสร้างใหม่ - ห้ามเรียก
destroyCachedClients()—realtime.service.ts:54-56แค่ clear map ไม่ stop socket และไม่รักษา subscriber
9.2 Model + mapper (ตัวที่ทำให้ของไหลถึง UI)
NotificationApiItem.scopeมีอยู่บน wire แล้ว (notification.model.ts:44-51) แต่mapNotificationItem(notification.service.ts:124-135) ทิ้งทิ้ง → ต้องเก็บเข้าNotificationItem- เพิ่ม
scopeในNotificationItem— ไม่ต้องมีappCode/appIdเพราะ D-5 กรองที่ server แล้ว FE ได้เฉพาะของที่มีสิทธิ์ markAsRead(id)(:106-113) ไม่ส่ง scope → ต้องส่งเพื่อ route ตรงตาม §7.4
9.3 Thundering herd — ต้องแก้ ไม่ใช่ nice-to-have
วันนี้ทุก Notification event ทำ 2 อย่างพร้อมกัน: notification.state.ts:31-37 เรียก loadInitial() (refetch หน้า 1, เคลียร์ items) และ realtime-app-connection.service.ts:76 เรียก loadUnreadCount()
⇒ ประกาศบริษัท 1 ใบ = 2 × N REST request ภายในวินาทีเดียว (N = สมาชิกที่ online) — personal push ไม่เคยเจอเพราะยิงทีละคน
ต้องเลือกอย่างน้อย 1: เพิ่ม jitter/debounce ก่อน refetch · หรือ บวก badge ใน local state แทนการ re-query แล้วค่อย refetch ตอนเปิดกระดิ่งจริง
9.4 UI
- ลิสต์ต้องรองรับ
scope === 'Company'(ไอคอน/label แยก) NotificationFilterวันนี้มีแค่'all' | 'unread'(notification.model.ts:5) — ถ้าจะให้เลือกดูทีละบริษัทต้องเพิ่มมิติใหม่ (ไม่อยู่ใน D-1 ที่เลือก จึงยังไม่ต้องทำ)
9.5 ⚠️ ถ้า D-1 ถูกพลิกกลับเป็น active-company-only เท่านั้น
- entry point จริงคือ
switchCompany(corporate-panel.state.ts:66-84) ไม่ใช่setDefaultCompany— และมัน chainsyncUserInfoAndPerm → setDefaultCompanyใต้finalizelock อยู่แล้ว - ห้ามเปลี่ยน
tap()เป็นswitchMap()ที่:44-61— จะทำให้บล็อกที่ setuserSignal({...isDefault...})หลุด ⇒currentCompany()(:28-31) คืนบริษัทเก่า ชื่อ/avatar/isSelected ค้าง (นี่คือ bug ที่อยู่ใน snippet ของ Plan C เดิม) - hook refresh เดิม filter
prev==='reconnecting' && curr==='connected'(realtime-app-connection.service.ts:60-69) — manual disconnect/connect ให้disconnected→connecting→connected= ไม่ match ⇒ ต้อง hand-roll reload เอง
10. Security requirements
| ต้องมี | |
|---|---|
| 10.1 trigger | ✅ ปิดแล้วด้วย D-3 — ASB consumer เท่านั้น ห้ามสร้าง HTTP endpoint สำหรับประกาศบริษัทเด็ดขาด และ ห้ามลอก test-push/test-broadcast (NotificationsController.cs:88-110 = [Authorize] ล้วน) มาเป็นแม่แบบ · consumer ทำตาม pattern FlowStatusNotificationConsumer (topic/subscription เป็น const + CreateProcessor + AddHostedService ใน DI) |
| 10.2 SourceService | derive จาก authenticated caller ห้ามรับจาก body (ไม่งั้น audit ปลอมได้ + ชน dedup namespace) |
| 10.3 detail | membership guard + 404 (§7.6) |
| 10.4 ActionLink | allowlist server-side: relative path หรือ https + host จาก config — ห้าม javascript: / data: / external. นี่คือสิ่งที่ทำให้ประกาศปลอมยกระดับจาก spam เป็น account takeover |
| 10.5 Title/Subtitle/Detail | ประกาศเป็น text-only + max length ที่ validator |
| 10.6 SQL | FromSqlInterpolated/DbParameter ทุกค่ารวม LIMIT/OFFSET + whitelist Scope/sort key |
| 10.7 logging | CompanyId / NotificationId / SourceService เท่านั้น + metric ตอน push fail |
| 10.8 group | ✅ verified ว่า client join group เองไม่ได้ — AppHub.cs:38 มี client method เดียวคือ CheckAppStatus ที่ filter cached list |
| 10.9 app scoping | ✅ ปิดแล้วด้วย D-5 — กรอง AppId ที่ server ทั้ง list / unread / detail · UserAppInfo.AppId เป็น string? ⇒ parse เป็น Guid ก่อนใช้ และ ค่าที่ parse ไม่ได้ต้องทิ้ง ไม่ใช่ถือว่าผ่าน |
11. Traceability — 41 finding ไปอยู่ไหน
| Finding | สถานะ | ที่ |
|---|---|---|
| B1 dedup key | absorbed | §4.3 unique index, §6 |
| B2 race / ON CONFLICT | absorbed | §6, §7.4, §7.5 |
| B3 cache = authz boundary | partially deferred | §5 (guard + revocation lag) + P-2/P-3 — ⚠️ D-1 แก้เฉพาะ setDefaultCompany dependency ไม่ได้แก้ B3 |
| B4 trigger authz | ปิดแล้ว | D-3 — ASB เท่านั้น ไม่มี public surface |
| B5 union query | absorbed | §7.2 |
| H-01 all-companies | chosen | D-1 |
| H-02 hub identity | absorbed (downgraded) | §8.2 |
| H-03 containment guard | absorbed | §5 |
| H-04 app scoping | ปิดแล้ว | D-5 — AppId + กรองที่ server |
| H-05 Scope vs Source | absorbed (ขยาย Scope) | §7.3 |
| H-06 UnreadCount combined | absorbed | §7.3 |
| H-07 growth bound | ตัดสินแล้ว (รับ trade-off) | D-4 — receipt อย่างเดียว พึ่ง D-2; ต้องเฝ้าขนาดตาราง |
| H-08 DefaultCompanyId cross-check | absorbed (ไม่ต้องใช้แล้วเพราะ D-1) | §5 |
| H-09 Status filter | absorbed | §5 |
| H-10 index shape | absorbed | §4.3 |
| H-11 detail IDOR | absorbed | §7.6 |
| H-12 repo SaveChanges | absorbed | §7.4 |
| H-13 mass assignment | absorbed | §5 กฎ 1-2 |
| H-14 history exposure | chosen | D-2 |
| H-15 GetForUpdateAsync ไม่ lock | absorbed | §7.4 |
| C-01 commit-vs-push | absorbed | §6.1 |
| M-01 mark-read probe | absorbed | §7.4 |
| M-02 multi-tab | ไม่เกิดแล้ว | D-1 ลบ active-company scoping ทิ้ง |
| M-03 remove group | absorbed | §8.1 |
| M-04 scoping ไม่สม่ำเสมอ | ไม่เกิดแล้ว | D-1 |
M-05 c.Id compile ไม่ผ่าน | absorbed | §5 ใช้ CompanyId |
| M-06 SourceService spoof | absorbed | §10.2 |
| M-07 raw SQL / Scope validator | absorbed | §7.2, §10.6 |
| M-08 ActionLink/XSS | absorbed | §10.4-10.5 |
| M-09 null → 0 row | absorbed | §5 กฎ 4 |
| M-10 reconnect catch-up | absorbed | §9.3 |
| M-11 ทำไมไม่ใช้ Broadcast | ตอบแล้ว | Broadcast fan-out 1 row/user และไม่มีมิติบริษัท — ดู §1 หลักการ 2 |
| M-12 repo count / MIGRATION | absorbed | P-1 (+2 repo), แผน §File 3 |
| M-13 FK | absorbed | §4.3 |
| M-14 now()/Kind=Utc | absorbed | §7.5 |
| L-01 logging | absorbed | §10.7 |
| L-02 interceptor bypass | absorbed (ยอมรับ) | §7.2 |
| L-03 UpdatedAt/By | absorbed | §4.4 (ห้ามตัด interface) |
| L-04 migration pattern | → File 3 | |
| L-05 PgBouncer | → File 3 | |
| L-06 broken links | absorbed | เอกสารนี้ไม่มี relative link ไป repo |
| ใหม่ (counsel) thundering herd | absorbed | §9.3 |
| ใหม่ (counsel) single subscriber | absorbed | §9.1 |
ใหม่ (counsel) FE model/mapper drop scope | absorbed | §9.2 |
12. Deploy ordering
P-1 Backend_Package (JoinedAt) → publish
→ Backend_UserService (mapper) → deploy [P-2 invalidation ควรมาพร้อมกัน]
→ Backend_NotificationService (migration → code) → deploy
→ Frontend_HostAppSuperApp → deploy
→ เปิด ASB consumer (หลัง topic/contract ตกลงกับทีม producer และ FE พร้อม)
- migration apply ก่อน deploy code ได้ (code เก่าไม่รู้จัก table ใหม่) · rollback = drop 2 table
- BE ก่อน FE เสมอ · trigger เปิดท้ายสุด — ไม่งั้น client เก่าได้ประกาศที่มันแสดงไม่ถูก
- ถ้าเพิ่ม config key ใดๆ → ต้อง update
Backend_Iacครบทุก env ไม่งั้น CI parity gate fail build
13. Test charter
Concurrency
- ยิง event เดียวไป 2 บริษัท → 2 row; ยิงซ้ำ → ยัง 2 row
- 2 process ยิงพร้อมกัน (
externalMessageId+companyIdเดียวกัน) → 1 row, ไม่มี 23505, push ครั้งเดียว - double-click mark-read → 200 ทั้งคู่, receipt 1 row, ไม่มี 25P02
- 2 tab mark-all พร้อมกัน → 200 ทั้งคู่, unread = 0
Authorization
- user บริษัท A เปิด detail / mark-read ด้วย id ของบริษัท B → 404, body ไม่มี Title/Detail
- ยัด
companyIdของ B ผ่านทุก binding surface (query/body/header/JSON field เกิน) → 0 row Status != Active→ ปฏิเสธภายใน lag ที่ประกาศ และ WS ที่เปิดค้างต้องหยุดได้ push- user ไม่มีบริษัท → unread = personal เป๊ะ, list = personal เท่านั้น (assert จำนวน row)
- ไม่มี HTTP endpoint สำหรับสร้างประกาศบริษัทอยู่ในระบบเลย (grep route ทั้ง service = 0) — D-3
D-5 (app scoping)
- ประกาศที่มี
AppIdของ app ที่ user ไม่มีสิทธิ์ → ไม่เห็นทั้ง list, count, detail และไม่มาทาง SignalR frame - ประกาศที่
AppId IS NULL→ เห็นทุกคนในบริษัท - blob ที่
Apps[].AppIdparse เป็น Guid ไม่ได้ → ค่านั้นถูกทิ้ง ไม่ทำให้ user เห็นเพิ่ม
D-2
- ประกาศก่อน
joinedAt→ ไม่เห็นทั้งใน list, count, detail JoinedAt == null(blob เก่า) → เห็นทั้งหมด (fail-open ตามที่ประกาศใน P-1)
Paging / correctness
- insert 50 row ใน SaveChanges เดียว (
CreatedAtเท่ากัน) → ไล่ page 1-3 → ไม่มี Id ซ้ำ/หาย,TotalCountคงที่ ?isRead=false¬ificationType=Info→ ทุก row ตรง filter และTotalCount= จำนวนหลัง filter?scope=' OR 1=1--→ validation error ไม่มี SQL error
Perf
EXPLAIN (ANALYZE)unread → Index (Only) Scan + anti-join, ไม่มี Seq Scan; union page 1 → ไม่มี Sort node บน branch company
FE
- ประกาศบริษัทเข้ามา → ไม่เกิด refetch storm (วัดจำนวน request ต่อ event)
- reconnect → list + count ตรงกับ DB
- กดประกาศบริษัท → เข้าหน้า detail ได้ (ไม่ 404)
scopeไหลจาก API ถึง UI จริง
E2E ต้องเป็น headed browser จริง กดผ่านหน้าจอทุก step ห้าม seed / ห้ามยิง API สร้างข้อมูล