Private Docs

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ผลถ้าไม่แก้
B1idempotency key เป็น global ExternalMessageId (§4.2)event เดียวส่งหลายบริษัท → บริษัทที่ 2..N หายเงียบ
B2check-then-insert ทุกจุด ไม่มี unique constraint / ON CONFLICT (§4.2, §6.4, §6.5)ประกาศซ้ำ + 23505 → txn abort → 500
B3membership cache = authorization boundary ที่ TTL 24 ชม. (§6, §7.3)คนถูกถอดจากบริษัทยังอ่านได้ ≤24 ชม. + จุดขายข้อ 3 ใช้ไม่ได้จริง
B4trigger ยังไม่ตัดสิน + ไม่มี 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 ข้อ

  1. Live delivery = company group — connection join company-{activeCompanyId} ตอน connect (อ่าน DefaultCompanyId จาก RedisUserInfo) → push เข้า group นั้น สมาชิก self-register เอง ไม่ต้องถาม UserService ว่าใครอยู่บริษัทไหน
  2. Persistence = shared row — เก็บ 1 row ต่อ 1 ประกาศ (ไม่ fan-out) + ตาราง read-receipt แยกว่าใครอ่านแล้ว
  3. 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 list IN Companies ทั้งคู่ก็ consistent และ §7 ทั้งหัวข้อหายไปพร้อมกัน (ไม่ต้อง reconnect, ไม่ต้องพึ่ง UserService invalidate setDefaultCompany, FE เหลือแค่ appCode filter) — ตัวเลือก active-only เป็นตัว สร้าง blocker B3 ของตัวเอง ดู §12 H-01 และ §16


2. Data Model (ตารางใหม่ 2 ตัว)

2.1 company_notifications — 1 row ต่อ 1 ประกาศ

คอลัมน์ชนิดหมายเหตุ
IdGuid PK
CompanyIdGuidบริษัทเป้าหมาย
AppCodestring? (50)null = ทุก app (client กรอง)
Title, Subtitlestring
SpecialDetail, Detailstring?
NotificationTypestringเดิม (Info/…)
ActionLinkstring?
SourceServicestring?audit
ExternalMessageIdstring?idempotency
CreatedAt, CreatedByaudit

Index: (CompanyId, CreatedAt) — ดึงประกาศของบริษัทเรียงเวลา

⚠️ [REVIEW B1 + H-10] index นี้ ไม่พอ และ ขาด unique constraint สำหรับ dedup

  • ต้องเป็น (CompanyId, CreatedAt DESC, Id DESC) — เพราะ tiebreaker Id ต้องอยู่ใน 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 เมื่ออ่าน)

คอลัมน์ชนิดหมายเหตุ
CompanyNotificationIdGuidFK → company_notifications
UserIdGuid
ReadAtDateTime

PK ประกอบ: (CompanyNotificationId, UserId) — กันอ่านซ้ำ + lookup เร็ว Index เสริม: (UserId, CompanyNotificationId) — ใช้ตอนคำนวณ unread ต่อ user

Model: “ไม่มี receipt = ยังไม่อ่าน” (แถวเกิดเฉพาะตอนอ่าน — ประหยัด row)

⚠️ [REVIEW H-07 — ต้องตัดสินใจ ก่อน สร้าง table เพราะเปลี่ยนทีหลังแพง]

  1. “ประหยัด row” จริงเฉพาะของที่ยังไม่อ่าน — ของที่อ่านแล้วโต O(users × notifications) = เท่ากับ fan-out ที่เอกสารนี้ตั้งใจหลบ
  2. ชนกับจุดขาย §1.3: คนเข้าใหม่เห็นย้อนหลัง = unread ของเขาคือ ประวัติทั้งหมด (1,000 ประกาศ → badge “1000”) และ §6.5 หนึ่งคลิก = INSERT N row ไม่มีเพดาน ใน request เดียว
  3. ไม่มี 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 แล้ว เรียก REST loadUnreadCount() เอง (ของเดิม 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): repo SaveChangesAsync เอง = commit จริงเกิดก่อน push ⇒ ordering ถูกตามที่ §4.2 ตั้งใจ
  • path ที่มี ambient transaction (ผ่าน MediatR ICommandTransactionBehavior BeginTransactionAsync แล้ว 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?)ไม่มี field AppCode เลย (UserInfoForRedis.cs:144) ขณะที่ AppCode มีอยู่ใน app-status contract คนละตัว ⇒ “ใช้ Apps จาก blob scope ที่ server ได้” อาศัยสมมติว่า AppId ≡ AppCode ซึ่งยังไม่มีหลักฐาน ⇒ ยกเป็น open question ใน Task 0: mapping AppId ↔ AppCode มีจริงไหม ถ้าไม่มี ต้องหาแหล่ง entitlement อื่นก่อนจะ scope ฝั่ง server ได้
  • M-06 SourceService spoofable — เป็น 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 ข้อ:

  1. วิธีพิสูจน์ยังไม่ถูกกำหนด และพิสูจน์บน 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 cache
  2. HttpContext.Items ไม่ใช่สัญญาที่ปลอดภัยกับ long-lived connection — pattern GetUserInfo() ออกแบบมาสำหรับ per-request controller; ถ้า client fallback ไป ServerSentEvents/LongPolling request ต้นทางจบไปแล้ว (กระทบ OnDisconnectedAsync เต็มๆ — ดู M-03)
  3. degrade เงียบif (activeCompanyId is not null) แปลว่าเมื่อ resolve ไม่ได้ user จะ ไม่ได้ company push ตลอด lifetime ของ connection โดยไม่มี log ✅ ทางที่ deterministic กว่า (verify แล้วว่าทำได้): InjectUserClaims re-add identity เดิมกลับเข้า principal (RedisUserInfoMiddleware.cs:233-234) → oid ยังอ่านได้จาก Context.User และ IOptions<RedisUserInfoOptions> ถูก register จริง (SecurityExtensions.cs:91) → อ่าน Redis ตรงใน OnConnectedAsync (~15 บรรทัด, key {KeyPrefix}:user:info:{oid}) ตัด dependency HttpContext.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) ห้ามถือว่า unfiltered
  • if (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ทำไมพัง
1FromSql บน DbSet<T> ต้องคืน column ครบตาม Tshape ของ union นี้ไม่ตรงกับ InAppNotification และไม่ตรงกับ CompanyNotification → runtime InvalidOperationException: The required column 'X' was not present. ต้องประกาศ keyless read-model (HasNoKey()) แล้ว _db.Set<NotificationListRow>().FromSqlInterpolated(...)
หมายเหตุ: repo นี้ ไม่มี raw SQL ที่ไหนเลย (grep FromSql|ExecuteSql|SqlQuery ใน src = 0) และ ไม่มี Dapper ใน csproj → “หรือ Dapper” = เพิ่ม dependency ใหม่ + ต้องส่ง GetDbTransaction() เองไม่งั้นหลุด transaction ⇒ อย่าใช้ Dapper
2ไม่ project Scope เลยNotificationItemDto field 6 คือ string Scope (non-null) และเป็นคนละความหมายกับ Source ที่ §6.3 เพิ่ม → ยุบรวมกัน = filter scope เดิมพัง + broadcast row กลายเป็น 'Personal'. ต้อง project ทั้งคู่: personal → "Scope" (column จริง) + 'Personal' AS "Source"; company → 'Company' AS "Scope" + 'Company' AS "Source"
3filter 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) = @isRead
4ไม่คำนวณ TotalCountNotificationListResponse(Items, **TotalCount**, UnreadCount, Page, Size) บังคับ → ถ้าไม่มีต้องส่ง 0 = pagination FE พัง
5ORDER 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" DESC
6alias ไม่ quoterepo นี้ไม่มี 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.cs validate แค่ UserId/Page/Size/NotificationType) = free string จาก [FromQuery] → ถ้า concat ลง SQL คือช่องโหว่. บังคับ FromSqlInterpolated/DbParameter ทุกค่ารวม LIMIT/OFFSET + whitelist Scope ใน validator
  • L-02: raw SQL ข้าม AuditableEntityInterceptor (เป็น SaveChangesInterceptor) — ยอมรับได้เพราะ receipt ไม่มี audit field แต่ต้องรู้ไว้ว่าทุก raw/ExecuteUpdate บน company_notifications ในอนาคตจะข้าม audit เงียบๆ (precedent: MarkAllAsReadAsync เดิม set UpdatedAt มือ แต่ ไม่เคย set UpdatedBy)

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 + มี defaultGetNotificationsHandler.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.UnreadCountGetNotificationsHandler.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 จริง:

  1. request A และ B ผ่าน AnyAsync ทั้งคู่ (ยังไม่มี receipt)
  2. ทั้งคู่ Add + SaveChanges → คนที่สองได้ 23505: duplicate key value violates constraint "PK_company_notification_reads"
  3. MarkNotificationReadCommand เป็น ICommandTransactionBehavior เปิด 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 ไม่กระทบทางแก้)
  4. ไม่มี retryEnableRetryOnFailure ถูกปิดโดยเจตนา (DependencyInjection.cs:64-67) แก้: ทิ้ง pre-check ใช้ upsert ตรงๆ (correct ทั้งกรณีมี/ไม่มี ambient txn — ExecuteSqlInterpolatedAsync enlist ใน 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() ผิด clocknow() = transaction start time และเป็นคนละ clock กับ DateTime.UtcNow ที่ใช้ทุกที่ในโค้ด ⇒ ส่ง @now เข้ามาแทน
  • ReadAt ต้องเป็น Kind=Utc ไม่งั้น Npgsql throw Cannot 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 ใหม่) ซึ่งอันตรายกว่าเดิม

✅ เวอร์ชันที่ถูก (รวม Status filter ตาม 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/UserCompanyIdsover-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) :

  1. group membership ยังเป็นบริษัทเก่า (ตั้งตอน connect ไม่อัปเดตเอง)
  2. กระดิ่ง (scope active company) ยังโชว์ของบริษัทเก่า
  3. 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 (:173 StringSetAsync(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 ไม่มี section RedisUserInfo เลย ⇒ 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

สิ่งที่ต้องมีก่อนเริ่ม (เลือกทางใดทางหนึ่ง):

  1. invalidation contract ครอบ 3 event (default-change + add-membership + remove-membership) merged ที่ UserService ก่อน — ไม่ใช่ “คุยกัน” แต่เป็น PR ที่ผูกวันที่ได้
  2. หรือ ให้ read path ตรวจ membership แบบไม่ผ่าน cache (แพงกว่า แต่ตัด dependency)
  3. หรือ ตัด §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 แต่ไม่จริงระดับ userDefaultCompanyId เป็น 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 SignalRreuse 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 ก่อนเปิด trigger
DTO(ไม่มีในตาราง)เพิ่ม Source ทับ Scope เดิม = shared vocabulary change ต้องนับเป็น medium risk (H-05)

9. Checklist งาน

Task 0 (ทำก่อน — พิสูจน์สมมติฐาน)

  • Context.GetHttpContext()?.GetUserInfo() ใน OnConnectedAsync ได้ค่าจริง (DefaultCompanyId, Companies ไม่ null)
  • ยืนยันกับทีม UserService: setDefaultCompany invalidate 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 → verify has-pending-model-changes = none → merge MIGRATION กลับเข้า feature. branch มีจริงในโปรเจกต์นี้แล้ว (.git/refs/heads/MIGRATION) — หมายเหตุ: precedent เรื่อง lineage เพี้ยนจากการ author ผิด branch เป็นของ UserService ไม่ใช่ service นี้ (นอกขอบเขตรีวิว) แต่กฎยังบังคับเหมือนกัน
  • งาน trigger-side (ตัวที่ §9 list ไว้เองแต่ไม่มีในตาราง effort)
  • DbSet 2 ตัว + keyless read-model ใน AuthDbContext + AddScoped<ICompanyNotificationRepository> ใน DI (ไม่มีทั้งคู่ตอนนี้ → compile/DI error)
  • GetNotificationDetail company 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. 1 row ต่อประกาศ (ไม่ fan-out) + read-receipt table → กระดิ่งเต็มโดยไม่พึ่ง UserService reverse-lookup
  2. live = company group (join จาก DefaultCompanyId), count/history = query DB ตาม “บริษัทฉัน”
  3. unread คิดตอนอ่าน → คนเพิ่งเข้าบริษัทเห็นย้อนหลัง
  4. สลับบริษัท = disconnect + reconnect + reload (และ UserService ต้องเคลียร์ cache ก่อน — §7.3)
  5. 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) · precedent usvc-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 tabletable ว่างเปล่า, in_app_notifications ไม่ถูกแตะ
DTO เพิ่ม field เป็น positional-at-end + default → handler เดิมยัง compileGetNotificationsHandler.cs:22-30
§6.4 มี containment check ถูกต้อง (สเปกรู้ว่าต้องมี)§6.4 !userCompanyIds.Contains(...)
client join group เองตามอำเภอใจ ไม่ได้ — ไม่มี hub method ไหนรับชื่อ group จาก clientAppHub.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ตรวจเจอได้อย่างไรทางแก้เจ้าของ
B1idempotency key เป็น global ExternalMessageId (§4.2)upstream event เดียว fan-out หลายบริษัทใช้ EventId เดียวกัน (precedent จริง: WorkflowMakerDispatcher.cs:34 ยิง SendPersonalAsync วน recipients ด้วย externalMessageId ตัวเดียว) → บริษัทที่ 2..N ถูก skipสูง — เกิดทันทีที่มีประกาศส่งหลายบริษัทประกาศ หายเงียบ ไม่มี error ผู้ส่งได้ successcount row ต่อ ExternalMessageId หลังยิง 1 event ไป 2 บริษัท → ต้องได้ 2key = (ExternalMessageId, CompanyId) + partial unique index + log ระดับ Information ตอน suppressBE
B2check-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 → คำสั่งถัดไปตายด้วย 25P02500 และ ไม่มี retry (EnableRetryOnFailure ปิดโดยเจตนา DependencyInjection.cs:64-67)สูง — redelivery/double-click เป็นเรื่องปกติประกาศซ้ำในกระดิ่ง + HTTP 500 ตอน mark-read/mark-allยิง 2 request ขนาน → ต้องได้ 200 ทั้งคู่, receipt 1 row, ไม่มี 23505/25P02 ใน logINSERT ... ON CONFLICT DO NOTHING ทุกจุด + unique constraint จริง (ห้ามใช้ catch 23505 เพราะ txn abort แล้ว)BE
B3membership 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 lagUserService (blocking) + BE
B4trigger ยังไม่ตัดสิน (§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; ห้ามเพิ่ม DapperBE

12.2 Risk register — 🟠 High

IDความเสี่ยงกลไก / หลักฐานทางแก้
H-01scope 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-02Task 0 assumption GetHttpContext()?.GetUserInfo() load-bearing เกินกว่าที่ “⚠️ ต้องพิสูจน์” รับไหวพิสูจน์บน localhost ไม่มีความหมาย (FallbackEndpoint = cluster DNS :96, FallbackApiKey ว่างในไฟล์ :97cache miss ซ่อมตัวเองไม่ได้บนเครื่อง dev); HttpContext.Items ไม่ใช่สัญญาที่ปลอดภัยกับ long-lived connection; degrade เงียบ ไม่มี logTask 0 ที่รันได้ → §14; ทางเลือกที่ deterministic = อ่าน Redis ตรงใน hub ด้วย oid จาก Context.User (survive principal swap ที่ RedisUserInfoMiddleware.cs:233-234) + IOptions<RedisUserInfoOptions> (register แล้ว SecurityExtensions.cs:91)
H-03containment guard มีใน §6.4 แต่ไม่มีใน §6.1/§6.2/§6.5repo method รับ companyId ดิบ → caller ที่สอง (ASB / admin / ?companyId=) กลายเป็น cross-tenant IDOR โดยไม่ต้องแก้ repoguard ตัวเดียวใช้ทั้ง 5 path → §13.2
H-04AppCode ให้ 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-05Source ทับ Scope = vocabulary collisionทั้งคู่มีค่า "Personal"; consumer switch on ค่านี้ ⇒ shared-vocabulary change = medium risk ขั้นต่ำเหลือ discriminator เดียว: ขยาย Scope ด้วย "Company"
H-06NotificationListResponse.UnreadCount ยัง personal-onlyGetNotificationsHandler.cs:20 เรียก GetUnreadCountAsync(userId) → list มี company item แต่เลขไม่นับเปลี่ยนเป็น GetCombinedUnreadAsync + เพิ่มใน §9 checklist
H-07receipt 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-08DefaultCompanyId ไม่ถูก 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-09UserCompanyInfo.Status ไม่ถูกกรองที่ไหนเลยmembership ที่ถูก revoke/suspend ยังอ่านได้ (คูณกับ TTL 24 ชม.)Where(c => c.Status == "Active") ทุกจุดที่ build company id set
H-10index (CompanyId, CreatedAt) ไม่พอtiebreaker Id ไม่อยู่ใน index → planner Sort ทั้ง partition ทุกครั้งที่ page(CompanyId, CreatedAt DESC, Id DESC)§13.1
H-11company GetNotificationDetail อยู่ใน §9 checklist แต่ §6 ไม่เคย speccompany_notifications ไม่มี user column ⇒ implementation ที่ “ทำให้ compile ผ่าน” คือ GetByIdAsync(id) → ใครมี GUID อ่าน Detail/SpecialDetail (เนื้อหาเต็มที่ตั้งใจไม่ใส่ใน SignalR payload) ของบริษัทอื่นได้; id หาได้ไม่ยาก เพราะ notification.Id ถูก broadcast เข้า group ทั้งบริษัท. องค์กรนี้มี precedent (usvc-application-pdf-no-owner-checkUserService, นอกขอบเขตรีวิวนี้)spec company branch: WHERE n."Id"=@id AND n."CompanyId" = ANY(@companyIds) + LEFT JOIN receipt (NotificationDetailDto ต้องมี IsRead+ReadAt) + คืน 404 ไม่ใช่ 403 (กัน existence oracle)
H-12repo เรียก _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-13company 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-01ordering ของ commit-vs-push ไม่ถูกกำหนด และ §4.2 กับ H-12 ขัดกันเองpath ที่ไม่มี ambient txn (ASB เรียก service ตรง) → repo commit เองก่อน push = ถูก; path ที่ผ่าน MediatR ICommandTransactionBehavior commit หลัง handler ⇒ ถ้าทำตาม H-12 push จะเกิดก่อน commit → rollback แล้ว client เห็นประกาศที่ไม่มีใน DBเลือกหนึ่งทาง: ให้ write path นี้ commit เองนอก TransactionBehavior หรือ ทำตาม H-12 แล้วย้าย push ไปหลัง commit ด้วย outbox / post-commit hook
H-15GetForUpdateAsync ที่ §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-01mark-read “ลอง personal ก่อนไม่เจอค่อย company” = query ทิ้ง 1 ครั้งทุกครั้งที่กด companyส่ง Source จาก DTO เข้า command แล้ว route ตรง
M-02multi-tab / multi-device: live group (per connection) ไม่ sync กับ DefaultCompanyId (per user)H-01 หรือ broadcast switch event ไปทุก connection ของ user
M-03OnDisconnectedAsync remove company group = ceremony ไม่จำเป็น + อ่าน HttpContext หลัง connection จบไม่ต้องเขียน — SignalR ถอด group เองเมื่อ connection จบ
M-04scoping ไม่สม่ำเสมอ: list ใช้ DefaultCompanyId / mark-read gate ใช้ userCompanyIdsตัดสินให้ตรงกัน
M-05§6.6 c.Id compile ไม่ผ่าน (จริงคือ CompanyId)แก้ snippet (ดู §6.6)
M-06SourceService มาจาก caller แต่ถูก label ว่า audit = spoofable + ชนกับ dedup namespacederive จาก 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-08ActionLink/Title/Detail ไม่มี validationallowlist ActionLink (relative หรือ https + host จาก config); ประกาศ Title/Subtitle/Detail เป็น text-only + max length ⇒ ตัวนี้คือสิ่งที่ทำให้ B4 ยกระดับจาก spam เป็น account takeover
M-09null activeCompanyId ถ้าเขียนเป็น optional SQL predicate (@x IS NULL OR ...) จะคืน ทุกบริษัท — และ test §9 ตามที่เขียนไว้ ผ่านเขียน test เป็น assertion company part = 0 row เป๊ะ + short-circuit ใน C#
M-10reconnect ส่งแค่ count ไม่มี list catch-upreload 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-14now() (= txn start time, คนละ clock กับ DateTime.UtcNow) และ ReadAt ที่ Kind != Utcส่ง @now เข้ามา; DateTime.UtcNow เท่านั้น ไม่งั้น Npgsql throw Cannot write DateTime with Kind=Local...
L-01logging drift: catch ที่ swallow push failure ไม่มี metric; เสี่ยงมีคนใส่ payload.Title/PII ตอน debuglog เฉพาะ CompanyId + NotificationId + SourceService; เพิ่ม counter ตอน push fail
L-02raw SQL ข้าม AuditableEntityInterceptor เงียบ (precedent: MarkAllAsReadAsync เดิม set UpdatedAt แต่ไม่เคย set UpdatedBy)รับได้เพราะ receipt ไม่มี audit field — แต่ต้องรู้ไว้
L-03CompanyNotification มี 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-04migration pattern: ไฟล์อยู่ Migrations/ แต่ namespace เป็น ...Persistence.Migrations (mismatch มีอยู่เดิม); DateTime เดิมเป็น timestamptz ทุกตัว; index ใช้ descending: array + filter:ตามของเดิม; unique+partial index ต้องอยู่ใน migration เดียวกับ CreateTable
L-05PgBouncer (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-06relative 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 ที่ต้องมี:

  1. PK company_notifications(Id)
  2. IX_company_notifications_company_created (CompanyId, CreatedAt DESC, Id DESC) — list + tiebreaker
  3. IX_company_notifications_external_message_dedup UNIQUE (ExternalMessageId, CompanyId) WHERE ExternalMessageId IS NOT NULL
  4. PK company_notification_reads(CompanyNotificationId, UserId) — anti-join probe + upsert target
  5. IX_company_notification_reads_user_notification (UserId, CompanyNotificationId)ไม่ redundant
  6. FK company_notification_reads.CompanyNotificationId → company_notifications(Id) ON DELETE CASCADE
  7. (ถ้ารับ §13.3) company_notification_read_cursors PK (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_cursorsUserId, 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 Size 1..100 cap + whitelist Scope ใน validator (M-07)

14. Task 0 ที่รันได้จริง

Task 0 เดิมเป็น “ต้องพิสูจน์” ที่ไม่มีวิธีวัด — เวอร์ชันที่รันได้:

T0-1 — GetUserInfo() ใน OnConnectedAsync (ต้องรันบน dev cluster ไม่ใช่ localhost)

  1. deploy hub ที่ log RedisInjected, DefaultCompanyId, Companies.Count ตอน OnConnectedAsync (ยังไม่ต้อง join group)
  2. cold cache: DEL DEV_UserService:user:info:{oid} ก่อน connect → พิสูจน์ว่า fallback ทำงานผ่าน cluster DNS จริง (บนเครื่อง dev ทำไม่ได้ — FallbackEndpoint เป็น svc.cluster.local)
  3. connect ผ่าน APIM dev path จริง ด้วย transport ที่ client ใช้จริง และ บังคับ fallback LongPolling/ServerSentEvents อีกรอบ — ทั้ง 2 กรณีต้อง non-null
  4. ✅ pass = ทั้ง 4 combination (warm/cold × WS/long-polling) log DefaultCompanyId ได้ไม่ null
  5. ❌ 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&notificationType=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, reversiblePhase 2 — read-model rewrite
ทำอะไร2 table ใหม่ + constraint ครบ (§13.1) + SendCompanyAsync + NotifyCompanyAsync + hub join group ทุกบริษัทที่สังกัด (H-01) + list/count filter IN Companiesunion 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 membershipB3 เต็มรูป (default-change invalidation)
rollbackdrop 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 → verify has-pending-model-changes = none → merge กลับเข้า feature
  • process ถ้าเพิ่ม config key ใดๆ → update Backend_Iac ครบทุก env
  • §10 อัปเดต repo list + effort ให้ตรงความจริง (≥4 repo, deploy BE ก่อน FE)