Private Docs

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.mdaudit 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 ข้อ

  1. Live delivery = company group — connection join company-{id} ของ ทุกบริษัทที่ user สังกัด ตอน connect (สมาชิก self-register เอง)
  2. Persistence = shared row — 1 row ต่อ 1 ประกาศ (ไม่ fan-out) + ตาราง read-receipt แยก
  3. 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-1JoinedAt ใน UserCompanyInfo ของ blobBackend_Package + Backend_UserServiceD-2 ต้องใช้ · ข้อมูลมีอยู่แล้ว: UserCompanyMapping : IAuditableEntity มี CreatedAt (UserCompanyMapping.cs:43) และจุด map คือ UsersMapper.cs:240-247
P-2membership-change invalidation (add/remove)Backend_UserServiceไม่มี = คนถูกถอดยังอ่านได้ (authz) และคนเข้าใหม่ไม่เห็น (feature)
P-3ปิดช่อง re-warm ทับ invalidationBackend_Package / opsRedisUserInfoMiddleware.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 ประกาศ

คอลัมน์ชนิดหมายเหตุ
IdGuid PK
CompanyIdGuidบริษัทเป้าหมาย
AppIduuid NULLNULL = ทุก app · กรองที่ server เทียบ blob (D-5)
Titlevarchar(300)
Subtitletext
SpecialDetail, Detailtext?
NotificationTypevarchar(20)default Info
ActionLinkvarchar(1000)?ต้องผ่าน allowlist (§10.4)
SourceServicevarchar(100)?derive จาก authenticated caller ห้ามรับจาก body
ExternalMessageIdvarchar(255)?idempotency
CreatedAt, CreatedByaudit

4.2 company_notification_reads — read receipt

คอลัมน์ชนิด
CompanyNotificationIdGuid
UserIdGuid
ReadAttimestamptz

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) — tiebreaker Id ต้องอยู่ใน 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 ผิดด้านสำหรับ predicate r.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:47 filter ด้วย Entries<IAuditableEntity>() ⇒ ตัด interface = ไม่มีใคร set CreatedBy ที่ประกาศ .IsRequired() = NOT NULL violation ⚠️ ต้องเพิ่ม DbSet 2 ตัว + 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)

  1. company id derive ใน controller จาก HttpContext.GetUserInfo() เท่านั้น — ห้าม model-bind, ห้ามรับจาก query/body/header
  2. command/query record ที่มี UserId หรือ company ids ต้อง construct เอง ห้าม [FromBody] (validator เป็น shape-only ไม่ใช่ authz — MarkNotificationReadCommandValidator เป็นแค่ NotEmpty())
  3. repo method ห้ามรับ companyId ที่ไม่ผ่าน guard นี้
  4. resolve ไม่ได้ / ไม่มีบริษัท ⇒ 0 row เป๊ะ ห้ามแปลว่า unfiltered และ ห้ามเขียนเป็น optional SQL predicate (@x IS NULL OR ...) — ต้อง short-circuit ใน C#
  5. revocation lag ที่ประกาศ: เท่ากับอายุ blob ที่เหลือ (worst case = TTL เต็ม) ⇒ ต้องมี P-2 + P-3
  6. 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 ต่างกัน:

pathambient 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(...) แปลเป็น correlated NOT 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 ไม่ตรง → runtime InvalidOperationException)
  • ห้ามเพิ่ม 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. และฝั่ง FE NotificationApiItem.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 ทั้งคู่ → 23505 repo ห้ามเรียก _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-278 pin SignalRTransport.WebSockets และ signalr-realtime.client.ts:44-47 ทำให้ skipNegotiation = true ⇒ ไม่มี negotiate, ไม่มี fallback transport ⇒ P-4 เหลือทดสอบแค่ warm/cold blob × WebSocket ⚠️ แต่มีความเสี่ยงที่ใหญ่กว่าที่คาด: app.config.ts:258-261 bootstrap await 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 libstate: Signal<RealtimeConnectionState> + subscribe() + connect/disconnect ครบ และแอปเข้าถึง connection.invoke() ตรงได้อยู่แล้ว (realtime-app-connection.service.ts:128-141)

9.1 ⛔ Constraint ที่ห้ามละเมิด (ไม่งั้นต้อง publish lib)

  1. subscriber เดียวต่อ channelsignalr-realtime.client.ts:163 ทำ handlers.set(channel, handler) ⇒ ถ้ามีคนที่ 2 subscribe('Notification', ...) ตัวแรกจะถูกทับและตายเงียบหลัง reconnect. company handling ต้องอยู่ใน subscription เดิมของ RealtimeAppConnectionService ห้ามสร้างใหม่
  2. ห้ามเรียก 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 — และมัน chain syncUserInfoAndPerm → setDefaultCompany ใต้ finalize lock อยู่แล้ว
  • ห้ามเปลี่ยน tap() เป็น switchMap() ที่ :44-61 — จะทำให้บล็อกที่ set userSignal({...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 SourceServicederive จาก authenticated caller ห้ามรับจาก body (ไม่งั้น audit ปลอมได้ + ชน dedup namespace)
10.3 detailmembership guard + 404 (§7.6)
10.4 ActionLinkallowlist 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 SQLFromSqlInterpolated/DbParameter ทุกค่ารวม LIMIT/OFFSET + whitelist Scope/sort key
10.7 loggingCompanyId / 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 keyabsorbed§4.3 unique index, §6
B2 race / ON CONFLICTabsorbed§6, §7.4, §7.5
B3 cache = authz boundarypartially deferred§5 (guard + revocation lag) + P-2/P-3 — ⚠️ D-1 แก้เฉพาะ setDefaultCompany dependency ไม่ได้แก้ B3
B4 trigger authzปิดแล้วD-3 — ASB เท่านั้น ไม่มี public surface
B5 union queryabsorbed§7.2
H-01 all-companieschosenD-1
H-02 hub identityabsorbed (downgraded)§8.2
H-03 containment guardabsorbed§5
H-04 app scopingปิดแล้วD-5 — AppId + กรองที่ server
H-05 Scope vs Sourceabsorbed (ขยาย Scope)§7.3
H-06 UnreadCount combinedabsorbed§7.3
H-07 growth boundตัดสินแล้ว (รับ trade-off)D-4 — receipt อย่างเดียว พึ่ง D-2; ต้องเฝ้าขนาดตาราง
H-08 DefaultCompanyId cross-checkabsorbed (ไม่ต้องใช้แล้วเพราะ D-1)§5
H-09 Status filterabsorbed§5
H-10 index shapeabsorbed§4.3
H-11 detail IDORabsorbed§7.6
H-12 repo SaveChangesabsorbed§7.4
H-13 mass assignmentabsorbed§5 กฎ 1-2
H-14 history exposurechosenD-2
H-15 GetForUpdateAsync ไม่ lockabsorbed§7.4
C-01 commit-vs-pushabsorbed§6.1
M-01 mark-read probeabsorbed§7.4
M-02 multi-tabไม่เกิดแล้วD-1 ลบ active-company scoping ทิ้ง
M-03 remove groupabsorbed§8.1
M-04 scoping ไม่สม่ำเสมอไม่เกิดแล้วD-1
M-05 c.Id compile ไม่ผ่านabsorbed§5 ใช้ CompanyId
M-06 SourceService spoofabsorbed§10.2
M-07 raw SQL / Scope validatorabsorbed§7.2, §10.6
M-08 ActionLink/XSSabsorbed§10.4-10.5
M-09 null → 0 rowabsorbed§5 กฎ 4
M-10 reconnect catch-upabsorbed§9.3
M-11 ทำไมไม่ใช้ Broadcastตอบแล้วBroadcast fan-out 1 row/user และไม่มีมิติบริษัท — ดู §1 หลักการ 2
M-12 repo count / MIGRATIONabsorbedP-1 (+2 repo), แผน §File 3
M-13 FKabsorbed§4.3
M-14 now()/Kind=Utcabsorbed§7.5
L-01 loggingabsorbed§10.7
L-02 interceptor bypassabsorbed (ยอมรับ)§7.2
L-03 UpdatedAt/Byabsorbed§4.4 (ห้ามตัด interface)
L-04 migration pattern→ File 3
L-05 PgBouncer→ File 3
L-06 broken linksabsorbedเอกสารนี้ไม่มี relative link ไป repo
ใหม่ (counsel) thundering herdabsorbed§9.3
ใหม่ (counsel) single subscriberabsorbed§9.1
ใหม่ (counsel) FE model/mapper drop scopeabsorbed§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[].AppId parse เป็น 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&notificationType=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 สร้างข้อมูล