Private Docs

SA-1380 — FX Outstanding Notification: Architecture

สถาปัตยกรรม ต้นทาง → กลางทาง → ปลายทาง ของการแจ้งเตือน Forward Contract ใกล้ครบกำหนดแบบ in-app: topic ใหม่ + Business ใหม่ใน NotificationService, ของที่มีอยู่แล้วและไม่ต้องสร้าง, gate นอก scope, cutover และพื้นผิวที่ห้ามแตะ

อัปเดต: 2026-08-06

SA-1380 — FX Outstanding Notification: สถาปัตยกรรม ต้นทาง → กลางทาง → ปลายทาง

สิ่งที่เอกสารนี้ตอบ: สัญญา Forward ที่ใกล้ครบกำหนดเดินทางจาก AS/400 ไปโผล่ที่กระดิ่งในแอปของลูกค้าได้อย่างไร — ใครถือข้อมูลอะไร ที่ไหนเป็นคนแปลงมันเป็นข้อความ และอะไรคือของที่ มีอยู่แล้ว กับอะไรคือของที่ ต้องสร้าง

คู่กับ: FX_OUTSTANDING_NOTIFICATION_IMPLEMENTATION_PLAN.md — งานที่ต้องทำจริงเรียงตาม dependency พร้อม test coverage


0. อ่านตรงนี้ก่อน — 3 ข้อที่เปลี่ยนรูปงานทั้งหมด

0.1 🔴 เอกสารนี้ ล้ม decision §6 ของแผนเดิมบน branch FX

บน branch ที่ Owner ให้มา มีไฟล์ docs/FxOutstandingNotification-Plan-v2-CompanyFanout.md §6 “ไม่ต้องทำ” เขียนไว้ว่า:

❌ migration ใดๆ · ❌ IaC topic ใหม่ (ใช้ notification-personal เดิม) · ❌ แก้ NotificationService / Frontend (ใช้ appCode=FX เดิม)

⚠️ เรื่อง branch: ชื่อ branch นี้มีคำว่า origin/ เป็นส่วนหนึ่งของชื่อจริง (refs/heads/origin/feature/sprint-08/SA-1380-noti-0utstanding) ⇒ git rev-parse origin/feature/... จะ fail ต้อง fetch ด้วย refspec เต็ม: git fetch origin 'refs/heads/origin/feature/sprint-08/SA-1380-noti-0utstanding:refs/heads/sa1380-noti'

Owner ล้มข้อนี้เมื่อ 06/08/2026 — ต้องการ topic แยกและ business แยกสำหรับ FX โดยเฉพาะ เหตุผลคือ blast radius (FX พังไม่ควรลาม onboarding/OTP), template+config แยก, และเปิดทางให้ต่อ email (Jira AC3) ทีหลังโดยไม่แตะ path ที่ใช้ร่วมกัน

สองเอกสารนี้อยู่ร่วมกันไม่ได้ ผู้ implement ต้องอ่านเอกสารนี้เป็นหลัก และต้องรู้ว่าส่วนไหนของ v2 ตายแล้วบ้าง:

ส่วนของ v2สถานะ
§1 sequence, §3.D6 loop, decision D2 (ส่งให้ user active ทุกคนในบริษัท), D3 (สูตร messageId), D4 (currency=null)ยังใช้ได้ — สืบทอดมาในเอกสารนี้
กลไก scheduler: slot / Redis lock / re-check ใต้ lock / marker / Codex configยังใช้ได้ ไม่แตะ
§2.3 “UserService recipients (GET)” ที่ระบุ route /internal/fx-notification/recipients คืน {data:{recipients:[…]}}🔴 ตกยุค — ของจริงที่ merge แล้วคือ api/user-service/v1/companies/cust-codes คืน {data:{custCodes:[…]}} (CompanyController.cs:199-207) และ gateway ฝั่ง FX ก็ชี้ไปที่ route นี้อยู่แล้ว (UserServiceHttpGateway.cs:98-110)
§3.C ทั้ง section (สร้าง endpoint ใหม่ใน UserService)🔴 ตกยุค — งานนี้ทำเสร็จและ merge แล้วในรูปแบบอื่น
§4 Master Checklist บรรทัด UserService🔴 ตกยุค ตามข้อบน
§6 “ไม่ต้องทำ” ข้อ 2 (topic ใหม่) และข้อ 3 (แตะ NS/FE) + §3.D5 publisher ที่ยิง notification-personal🔴 ตกยุค — Owner ล้มแล้ว
§6 “ไม่ต้องทำ” ข้อ 1 (❌ migration ใดๆ)ยังใช้ได้ และแข็งขึ้น — เอกสารนี้ยืนยันซ้ำใน §0.3 และ §10 ข้อ 4 ว่างานนี้ต้องไม่มี migration
§3.A (FX_400) และ §3.B (ThirdPartyFX)⚠️ ยังไม่ทำ — เป็น gate G-1 ของเอกสารนี้ (ยืนยันแล้วว่ายังไม่มี endpoint ปลายทาง)
§5 ข้อควรระวัง⚠️ อ่านได้ แต่ข้อ 5 (“FX_Contract near-expiry เดิม ✅ ลบแล้ว”) ไม่ได้ตรวจซ้ำในรอบนี้

0.2 ✅ ของที่นึกว่าต้องสร้าง — สร้างเสร็จแล้วทั้งคู่

นึกว่าต้องทำความจริงหลักฐาน
สร้าง endpoint ใหม่ใน UserService เพื่อดึง custCode + userIdsมีแล้ว merge เข้า development แล้ว พร้อม unit testCompanyController.cs:199-207 (GET api/user-service/v1/companies/cust-codes, [AllowAnonymous] ระดับ action ที่บรรทัด 199 override [Authorize] ของ controller ที่บรรทัด 30) · GetCompanyCustCodesQuery.cs:16-19 คืน CompanyCustCodeItem(CustCode, CompanyId, UserIds) · CompanyRepository.cs:64-79 · tests/UserService05.Tests/.../GetCompanyCustCodesHandlerTest.cs
เพิ่มคอลัมน์แยก app ใน In-App Notification + migrationมีแล้วAppCode ลง development เมื่อ 05/08/2026 (วันก่อนหน้า session นี้) และเดินครบสาย write → store → read filter → unread filter → SignalRmigration 20260805045911_AddAppCodeToInAppNotifications · InAppNotification.cs:23,68 · InAppNotificationConfiguration.cs:50-53 (HasMaxLength(50).IsRequired(false)) · InAppNotificationRepository.cs:54,84 · GetNotificationsQuery.cs:14 · GetUnreadCountQuery.cs:7 · NotificationItemDto.cs:12 · InAppNotificationService.cs:52,61

⚠️ กับดักที่ทำให้เกือบวางแผนผิด: local checkout ของทั้ง UserService และ NotificationService ค้างเก่ากว่า origin/development (NS ตามหลัง 5 commit) ข้อเท็จจริงทั้ง 2 แถวข้างบนมองไม่เห็นจาก working tree ต้องใช้ git show origin/development:<path> ถึงจะเจอ — ผู้ implement ต้อง git fetch + ตรวจกับ origin/development ก่อนเริ่มเสมอ

0.3 ✅ “สร้าง Business ใหม่” ทำได้โดย ไม่มี migration เลย

NotificationService ไม่มี entity/table ชื่อ Business หรือ Channel (ตรวจแล้ว: AuthDbContext มีแค่ NotificationDeliveries, InAppNotifications, EmailTemplateKeys, EmailMacros, EmailTemplates; NotificationDelivery.ChannelId เป็น Guid? ลอย ๆ ไม่มีตารางรองรับ) — ดังนั้น “Business” ไม่ใช่สิ่งที่ไป ค้นหา ได้ แต่เป็นสิ่งที่เอกสารนี้ต้อง นิยาม

นิยามที่เลือก — Business = ช่องทางที่ประกอบจาก 4 อย่างที่มีอยู่แล้ว ไม่ใช่ตารางใหม่:

องค์ประกอบค่าของ FXมีอยู่แล้ว/สร้างใหม่
ASB topic + subscription เฉพาะตัวfx-inapp-event / FxInApp-Sub🆕 สร้าง (IaC)
Consumer + Dispatcher คู่เฉพาะตัวFxInAppEventConsumer + FxInAppNotificationDispatcher🆕 สร้าง (NS)
ตัวประกอบข้อความเฉพาะตัวformatter ใน dispatcher (ดู §4.1 — ไม่ใช่ template engine ของ email)🆕 สร้าง
ตัวแยกแยะบน record ปลายทางAppCode="FX" + SourceService="FxOrchestrator"✅ คอลัมน์มีแล้ว

เทียบกับทางเลือกที่แพงกว่า: ถ้านิยาม Business เป็น entity+table จริง จะต้องมี migration ใน NotificationService ซึ่ง repo นี้มี gotcha ที่ยืนยันแล้วว่า migration chain replay จากศูนย์ไม่ได้ (สร้าง env ใหม่/DR ต้องใช้ physical restore) — เพิ่ม migration ที่ไม่จำเป็นคือเพิ่มหนี้ให้ gotcha นั้น โดยไม่ได้ประโยชน์อะไรที่ AppCode + topic แยก + dispatcher แยก ให้ไม่ได้อยู่แล้ว

🔎 ความซื่อสัตย์ของนิยามนี้ — อ่านให้จบก่อนเห็นชอบ

“สร้าง Business ใหม่” เป็นคำที่ตีความได้ 3 ทาง และเอกสารนี้เลือกทางที่ถูกที่สุด นี่คือการตีความ ไม่ใช่สิ่งที่ Owner ระบุตรง ๆ:

การตีความได้อะไรราคา
(ก) ช่องทางแยกระดับ runtime ← เอกสารนี้เลือกtopic/consumer/dispatcher/discriminator แยก · blast radius แยก · ต่อ email ได้ไม่มี migration · ตรงกับ precedent WorkflowMakerDispatcher ที่ใช้ sourceService:"Workflow.Maker" อยู่แล้ว
(ข) ทะเบียน business เป็น entity+table + หน้าจอ admin เปิด-ปิดเปิด-ปิด business ได้จากหน้าจอ · query รายการ business ได้migration ใหม่ + CRUD + หน้าจอ + seed ทุก env
(ค) ทำ NotificationDelivery.ChannelId ที่ ค้างครึ่งทางอยู่แล้ว ให้เป็น entity จริงปิดหนี้เดิมของ repo ไปด้วย (ChannelId เป็น Guid? ลอย ๆ แต่ ProcessBatchCommandValidator บังคับว่า “Batch.ChannelId is required”)migration + ต้องไปแตะ path email/batch ที่ใช้งานจริงอยู่ = blast radius ที่ Owner ต้องการเลี่ยง

Owner ล็อกไว้ชัดแค่ “topic ใหม่” ไม่ได้ล็อกว่า “ห้ามมี/ต้องมี table ใหม่” — ถ้าเจตนาจริงคือ (ข) หรือ (ค) ให้ตีกลับก่อน implement เพราะรูปงานเปลี่ยนทั้งใบ


1. ภาพรวม ต้นทาง → กลางทาง → ปลายทาง

flowchart LR
    subgraph SRC["ต้นทาง — Backend_FxOrchestratorService"]
        direction TB
        CFG["FX Codex ConfigurationMaster<br/>FxNotificationOutstanding<br/>Enabled · StartTimes · ExpireDate"]
        SCH["FxOutstandingNotificationScheduler<br/>(BackgroundService)<br/>Redis lock + slot marker/วัน"]
        PUB["FxOutstandingNotificationPublisher<br/>→ ASB topic"]
        CFG -->|refresh ทุก N วิ| SCH
        SCH --> PUB
    end

    subgraph EXT["แหล่งข้อมูล (นอก scope งานนี้)"]
        direction TB
        US["UserService<br/>GET /companies/cust-codes<br/>✅ มีแล้วบน development"]
        TP["ThirdPartyFX → FX_400<br/>POST /contracts/outstanding/near-expiry<br/>⚠️ prerequisite ยังไม่ยืนยัน"]
    end

    subgraph MID["กลางทาง — Azure Service Bus (Backend_Iac)"]
        TOPIC["topic: fx-inapp-event<br/>sub: FxInApp-Sub<br/>🆕 ต้อง provision"]
    end

    subgraph DST["ปลายทาง — Backend_NotificationService"]
        direction TB
        CONS["FxInAppEventConsumer<br/>(BackgroundService — ASB plumbing)"]
        DISP["FxInAppNotificationDispatcher<br/>(plain class — validate + ประกอบข้อความในโค้ด + settle)"]
        SVC["IInAppNotificationService.SendPersonalAsync<br/>✅ มีแล้ว"]
        DB[("InAppNotifications<br/>AppCode='FX'<br/>✅ คอลัมน์มีแล้ว")]
        HUB["AppHub → group user-{userId}<br/>event 'Notification'"]
        CONS --> DISP
        DISP --> SVC
        SVC --> DB
        SVC --> HUB
    end

    subgraph FE["ลูกค้า"]
        BELL["กระดิ่ง / Inbox<br/>GET /notifications?appCode=FX<br/>GET /notifications/unread-count?appCode=FX<br/>✅ API มีแล้ว"]
    end

    US -.->|"(1) custCode + userIds"| SCH
    TP -.->|"(2) สัญญาใกล้ครบกำหนด"| SCH
    PUB -->|"(3) 1 message / (สัญญา × user)"| TOPIC
    TOPIC --> CONS
    HUB -->|"realtime push"| BELL
    DB -->|"query ย้อนหลัง"| BELL

    style TOPIC fill:#fff3cd,stroke:#d39e00
    style CONS fill:#d4edda,stroke:#28a745
    style DISP fill:#d4edda,stroke:#28a745
    style TP fill:#f8d7da,stroke:#dc3545

อ่านสี: 🟨 = ต้อง provision ที่ IaC (นอก 3 repo) · 🟩 = โค้ดใหม่ที่ต้องเขียน · 🟥 = prerequisite ที่ยังยืนยันไม่ได้ (ดู §8)


2. ลำดับเวลาแบบเต็ม (sequence)

sequenceDiagram
    autonumber
    participant CX as FX Codex Config
    participant SC as FxOutstandingNotificationScheduler
    participant RD as Redis
    participant US as UserService
    participant TP as ThirdPartyFX → FX_400
    participant SB as ASB topic fx-inapp-event
    participant NC as FxInAppEventConsumer
    participant DP as FxInAppNotificationDispatcher
    participant DB as InAppNotifications
    participant HB as AppHub (SignalR)
    participant FE as กระดิ่งลูกค้า

    CX-->>SC: Enabled / StartTimes / ExpireDate (refresh, ไม่ต้อง redeploy)
    Note over SC: ตื่นทุก RefreshIntervalSeconds (default 900s)
    SC->>RD: slot ของวันนี้ถูกทำไปหรือยัง?
    RD-->>SC: ยัง → slot due
    SC->>RD: TryAcquireLock (TTL 5 นาที)
    RD-->>SC: ได้ lock (pod เดียวเท่านั้นที่ทำงาน)
    SC->>RD: re-check ใต้ lock (กัน 2 pod ยิงพร้อมกัน)
    SC->>US: GET /companies/cust-codes
    US-->>SC: [{custCode, companyId, userIds[]}]
    SC->>TP: POST /contracts/outstanding/near-expiry {custCode[], expireDate}
    TP-->>SC: [{custCode, contractNumber, currencyCode, outstandingAmount, maturityDate}]
    Note over SC: map custCode → userIds · คำนวณ daysLeft ด้วย TZ Asia/Bangkok
    loop ทุก (สัญญา × user ในบริษัทนั้น)
        SC->>SB: publish FxOutstandingNotificationMessage<br/>MessageId = fx-outstanding:{cust}:{fw}:{user}:{yyyyMMdd}:{HHmm}
    end
    SC->>RD: mark slot เสร็จ (TTL 48 ชม.)

    SB->>NC: deliver message
    NC->>DP: DispatchAsync(message, repos…)
    alt payload ผิดรูป / template หาย
        DP-->>NC: DeadLetter(reason)
        NC->>SB: DeadLetterMessageAsync (ไม่ retry — retry ไม่ช่วย)
    else DB/transient error
        NC->>SB: AbandonMessageAsync (retry ตาม MaxDeliveryCount)
    else สำเร็จ
        DP->>DP: ประกอบ title/subtitle ด้วย string interpolation ในโค้ด (ไม่ใช่ template engine)
        DP->>DB: SendPersonalAsync(userId, payload{AppCode='FX'},<br/>sourceService='FxOrchestrator', externalMessageId=MessageId)
        Note over DB: dedup ด้วย unique index<br/>(ExternalMessageId, RecipientUserId)
        DB-->>DP: บันทึกแล้ว (หรือข้ามเพราะซ้ำ)
        DP->>HB: push event "Notification" → group user-{userId}
        HB->>FE: {id, title, subtitle, notificationType, actionLink, appCode:'FX', unreadCount}
        DP-->>NC: Complete
        NC->>SB: CompleteMessageAsync
    end
    FE->>DB: GET /notifications?appCode=FX (ย้อนหลัง / เปิดแอปทีหลัง)

3. ตารางต่อ hop — คลาส · topic · config key · contract

#Hopคลาส/ไฟล์จริงtopic / routeconfig keycontractสถานะ
1อ่านค่าตั้งFxOutstandingNotificationSettingsProvider + …SettingsRefresherFX Codex ConfigurationMaster type FxNotificationOutstandingFxOutstandingNotificationSettings{ Enabled, StartTimes[], TimeZone, ExpireDate, RefreshIntervalSeconds }✅ มีแล้ว ไม่แตะ
2ตั้งเวลา + กันซ้ำFxOutstandingNotificationScheduler (FxOrchestrator01.API/BackgroundServices/)Redis lock fx-outstanding-notification:lock (TTL 5 นาที) · marker fx-outstanding-noti:{date}:{HH:mm} (TTL 48 ชม.)✅ มีแล้ว ไม่แตะ
3ดึงผู้รับUserServiceHttpGateway.GetFxNotificationRecipientsAsyncGET api/user-service/v1/companies/cust-codesUserApi:BaseUrl + UserApi:GetCompanyCustCodes{data:{custCodes:[{custCode, companyId, userIds[]}]}}✅ ทั้ง 2 ฝั่งมีแล้ว
4ดึงสัญญาOutstandingContractClient.GetNearExpiryAsyncPOST api/thirdpartyfx-service/v1/contracts/outstanding/near-expiryFxThirdPartyApi:GetNearExpiryOutstandingContracts (มีใน appsettings แล้ว){data:[{custCode, contractNumber, currencyCode, outstandingAmount, maturityDate}]}⚠️ ฝั่ง FX มีแล้ว · ปลายทางยังไม่ยืนยัน (§8)
5ส่งขึ้น bus🆕 IFxOutstandingNotificationPublisherAsbFxOutstandingNotificationPublisher / NullFxOutstandingNotificationPublisher (แทน AsbPersonalNotificationPublisher ที่ถูกลบ)topic fx-inapp-event🆕 ServiceBus:FxInAppTopicNameguard อยู่ที่ DI registration ไม่ใช่ ctorFxOutstandingNotificationMessage (§4)🆕 ต้องเขียน
6provision bustopic fx-inapp-event + sub FxInApp-SubBackend_Iac/config/{svc}/{env}/appsettings.jsondup-detection + MaxDeliveryCount + TTL🆕 นอก scope (§8)
7รับจาก bus🆕 FxInAppEventConsumer (Notification02.Infrastructure/Messaging/) — ลอกโครง ASB จาก FxEmailEventConsumertopic+sub เป็น const ในคลาส (ตาม house style)deserialize → FxOutstandingNotificationMessage🆕 ต้องเขียน
8ตัดสิน + ประกอบข้อความ🆕 FxInAppNotificationDispatcher (plain class) — ลอกการเขียน+dedup จาก WorkflowMakerDispatcherFxInAppDispatchResult{ Action, Reason, Description }🆕 ต้องเขียน
9ประกอบข้อความไทยformatter ภายใน dispatcher (string interpolation)(contract, daysLeft) → (title, subtitle) — ดู §4.1🆕 ต้องเขียน
10เขียน + pushIInAppNotificationService.SendPersonalAsync (InAppNotificationService.cs)SignalR event "Notification" → group user-{userId}SendPersonalAsync(recipientUserId, InAppNotificationPayload, sourceService, externalMessageId, ct)✅ มีแล้ว
11เก็บInAppNotificationsAppCode='FX' · SourceService='FxOrchestrator' · ExternalMessageId=MessageId · unique index (ExternalMessageId, RecipientUserId) filtered NOT NULL✅ มีแล้ว
12อ่านNotificationsController.cs:21 — route base api/notification-service/v{version:apiVersion}/notificationsGET ?appCode=FX · GET /unread-count?appCode=FX · GET /{id} · PATCH /{id}/read · PATCH /read-allNotificationItemDto{…, AppCode}✅ มีแล้ว — ⚠️ 3 จุดไม่แยก app ดู §6

4. Wire contract ใหม่ — FxOutstandingNotificationMessage

หลักการที่เปลี่ยน: วันนี้ต้นทาง ประกอบข้อความไทยเอง (FxOutstandingNotificationScheduler.cs:166-171 สร้าง "สัญญาใกล้ครบอายุ ภายใน N วัน" แล้วส่ง title/subtitle เป็น string สำเร็จรูป) — Owner ระบุว่าต้นทางควรส่ง “data ข้อมูลสัญญา พร้อมกับ user ที่จะได้รับ” ดังนั้นข้อความย้ายมาเรนเดอร์ที่ NotificationService

{
  "schemaVersion": 1,
  "messageId": "fx-outstanding:0107537002559:FW-1:9d37dde9-…:20260806:0900",
  "appCode": "FX",
  "sourceService": "FxOrchestrator",
  "notificationType": "Warning",
  "recipient": { "userId": "9d37dde9-…" },
  "contract": {
    "custCode": "0107537002559",
    "contractNumber": "FW-1",
    "currencyCode": "USD",
    "outstandingAmount": 250.50,
    "maturityDate": "2027-06-16",
    "daysLeft": 3
  },
  "occurredAt": "2026-08-06T09:00:00+07:00"
}

กฎที่ต้องบังคับใน dispatcher:

กฎเหตุผล
messageId ต้องมีเสมอ และถูกส่งต่อเป็น externalMessageIdเป็น dedup key เดียวที่ DB บังคับได้ — ปัจจุบัน PersonalSignalConsumer ไม่ส่ง ค่านี้ ทำให้ path เดิมไม่มี dedup ที่ระดับ DB เลย
daysLeft คำนวณที่ ต้นทาง ไม่ใช่ปลายทางNotificationService ไม่รู้ TZ ธุรกิจของ FX และไม่ควรรู้ ต้นทางมี options.TimeZone (Asia/Bangkok) อยู่แล้ว ถ้าให้ปลายทางคำนวณจะได้ผลต่างกันตามนาฬิกา pod
maturityDate เป็น date ไม่ใช่ timestampตัดปัญหา offset ข้ามวัน; ปลายทางใช้แค่แสดงผล
ทุกค่าที่มาจาก payload ต้อง escape ก่อนใส่ templateบทเรียนจาก fx-email-channel (HTML injection เป็น defect ที่เคยเจอจริง)
1 message = 1 ผู้รับให้ dedup key (ExternalMessageId, RecipientUserId) ทำงานตรง ๆ และให้ 1 คนพัง ไม่ลามคนอื่น
schemaVersion ต้องมีตั้งแต่ v1ถ้าเพิ่ม field ทีหลัง consumer เก่าจะ reject ได้อย่างตั้งใจ ไม่ใช่ตีความมั่ว

4.1 ⚠️ “เรนเดอร์ที่ NotificationService” หมายถึงอะไร (และไม่ได้หมายถึงอะไร)

หมายถึง: FxInAppNotificationDispatcher ประกอบ Title/Subtitle ด้วย string interpolation ธรรมดาในโค้ด จากฟิลด์ใน contract แล้วส่งเข้า InAppNotificationPayload — เป็น pattern เดียวกับที่ WorkflowMakerDispatcher ทำอยู่แล้วในบ้านตัวเอง

ไม่ได้หมายถึง: ต่อเข้ากับ IEmailTemplateKeyRepository / IEmailTemplateRepository / IEmailMacroRepository — กลไกนั้นเป็นของสาย email/ANE โดยเฉพาะ ไม่เคยถูกต่อเข้ากับ IInAppNotificationService เลย และ InAppNotificationPayload ไม่มีฟิลด์ template key ให้เริ่มด้วยซ้ำ การลาก template engine มาใช้กับ in-app = สร้าง infra ใหม่ที่ไม่มีใครขอ ผิดข้อ “เรียบง่ายก่อน”

ราคาที่ยอมจ่าย: แก้ข้อความต้อง deploy NotificationService (ไม่ใช่แก้ row ใน DB) — ยอมรับได้เพราะข้อความมีแบบเดียวและยังไม่มีใครขอให้แก้ได้ตอน runtime ถ้าวันหนึ่งต้องการจริง ค่อยยก formatter ขึ้นเป็น template แล้วเพิ่มฟิลด์ template key เข้า payload

สิ่งที่ dispatcher ห้ามทำเด็ดขาด:

  • ห้ามคำนวณ daysLeft ใหม่จาก maturityDate เทียบกับ “ตอนนี้” — ให้ถือว่า daysLeft เป็นตัวเลขทึบที่ต้นทางคำนวณมาแล้ว. NotificationService ไม่มี timezone provider เดินอยู่เลยใน DI จะได้เวลาของ pod (UTC) และถ้า message ถูก redeliver จาก backlog/DLQ ข้ามวัน ข้อความจะกลายเป็นค่าที่ผิดจากตอน publish
  • ❌ ห้ามไปดึงข้อมูลสัญญาเพิ่มจาก FX/ThirdParty — ทุกอย่างที่ต้องใช้ต้องอยู่ใน message แล้ว

ทำไมไม่ส่งเป็น batch (1 message ต่อสัญญา + recipients[]): ประหยัด message จริง แต่แลกกับ (ก) dedup ต่อคนต้องย้ายไปทำในโค้ดแทน index (ข) ผู้รับคนที่ 7 พังทำให้ทั้ง batch ถูก retry แล้วคนที่ 1-6 ได้ noti ซ้ำ — ปริมาณระดับนี้ (สัญญา × user ต่อวันไม่กี่ slot) ไม่คุ้มเสี่ยง ถ้าปริมาณโตจนเป็นปัญหาจริงค่อยเปลี่ยน แล้ว schemaVersion รองรับไว้แล้ว


5. ทำไมต้อง topic แยก — และมันกันอะไรได้จริง

ถ้าใช้ notification-personal ร่วมกัน (แผน v2 เดิม)ถ้าแยก fx-inapp-event (แผนนี้)
FX fan-out (สัญญา × user ทุกบริษัท) เข้าคิวเดียวกับ OTP / onboarding / workflow — FX ยิงพรวดทำให้ noti อื่นหน่วงคิวแยก คิวใครคิวมัน
PersonalSignalConsumer พัง = พังทุก feature พร้อมกันconsumer แยก ตายเฉพาะ FX (และ FxEmailEventConsumer มี pattern จับ exception ไม่ให้ล้ม host อยู่แล้ว — ลอกมา)
DLQ ปนกัน แยกไม่ออกว่าอันไหน FXDLQ ของ FxInApp-Sub เป็นของ FX ล้วน
ข้อความไทยฝังในโค้ด FxOrchestrator (service ที่ deploy บ่อยกว่า)ข้อความอยู่ใน dispatcher ของ NS — ยังต้อง deploy อยู่ดีเมื่อจะแก้ถ้อยคำ แต่ย้ายมาอยู่ใน service ที่เป็นเจ้าของเรื่องการแจ้งเตือน และทำให้ email เฟสหน้าใช้ข้อมูลชุดเดียวกันได้
ต่อ email (AC3) ต้องไปแตะ path ที่ใช้ร่วมกันเพิ่ม dispatcher ตัวที่สองบน topic เดิมได้เลย

6. ปลายทางครบ 4 ข้อที่ Owner ขอ — สถานะจริง

ข้อสถานะหลักฐาน / สิ่งที่ต้องทำ
persist ลง inbox query ย้อนหลังได้✅ ใช้ของเดิมทั้งหมดInAppNotifications + GET api/notification-service/v{version}/notifications?appCode=FX
push realtime ผ่าน SignalR✅ ใช้ของเดิมInAppNotificationService.cs:57-62 push event "Notification" เข้า group user-{userId} พร้อม appCode — ⚠️ แต่ unreadCount ใน push เป็นค่ารวมทุก app ดูกล่องล่าง
อ่าน/นับ unread + mark-as-read⚠️ ได้บางส่วน — endpoint /unread-count?appCode= ใช้ได้จริง แต่มีอีก 2 จุดที่ไม่แยก appดูกล่องล่าง
เตรียมโครงรองรับ email✅ ได้จากรูปแบบtopic เดิมรับ subscription+dispatcher ตัวที่สอง และ message พก contract data ไม่ใช่ข้อความสำเร็จรูป — ดู §7

⚠️ 3 จุดที่ปลายทาง “ยังไม่แยกต่อ app” — ต้องรู้ก่อนบอกลูกค้าว่าเสร็จ

จุดอาการหลักฐาน
unreadCount ใน SignalR pushpush บอกว่า appCode:"FX" แต่ unreadCount ที่แนบมาเป็นยอดรวมทุกแอปInAppNotificationService.cs:56 เรียก GetUnreadCountAsync(recipientUserId, ct) overload ที่ไม่รับ appCode
UnreadCount ใน envelope ของ GET /notifications?appCode=FXรายการถูก filter เป็น FX ถูกต้อง แต่เลข unread ใน envelope เดียวกันเป็นยอดรวมGetNotificationsHandler.cs:21 เรียก overload ที่ไม่รับ appCode
PATCH /read-allกด “อ่านทั้งหมด” ในแอป FX จะ mark ของทุกแอปendpoint ไม่รับ appCode

ผลต่อ FE: กระดิ่งในแอป FX ต้องใช้ GET /notifications/unread-count?appCode=FX เท่านั้น ห้ามใช้เลขจาก envelope ของ list หรือจาก push payload ไม่งั้นจะเห็นยอดของแอปอื่นปนมา

ทั้ง 3 จุดแก้ได้ แต่แตะ shared read path = blast radius ที่ Owner ต้องการเลี่ยง → ยกเป็น Owner decision G-6 ใน implementation plan ไม่ตัดสินเงียบ ๆ ในงานนี้


7. เส้นทางต่อ email ในอนาคต (ยังไม่ทำ)

🔴 แก้ข้อเสนอเดิมของเอกสารนี้เอง — ร่างแรกเสนอให้ตั้งชื่อ topic เป็น fx-notification-event (per-app) เพื่อให้ email เฟสหน้าใช้ topic เดียวกันได้ หลักฐานจาก IaC ล้มข้อเสนอนั้น:

Backend_Iac/docs/operation/SERVICEBUS_TOPICS_INVENTORY.md แสดงว่า FX มี topic email ของตัวเองอยู่แล้วfx-email-event (แถวที่ 15, สร้างจริง 31/07/2026 ครบ dev/sit/uat, duplicate detection ON window PT10M) subscription FxEmail-Sub และ convention ของทั้ง namespace เป็น per-channel/per-purpose ทั้งหมด (notification-personal, notification-broadcast, flow-status-notification, workflow-maker-notification, fx-email-event) ไม่มี topic per-app สักตัว

ผลที่ตามมา 2 ข้อ:

  1. ชื่อที่ถูกต้องคือ fx-inapp-event / FxInApp-Sub — เป็นพี่น้องกับ fx-email-event / FxEmail-Sub ตรงตาม convention ที่ใช้อยู่ ส่วน fx-notification-event จะทับความหมายกับ fx-email-event (ซึ่งก็เป็น “FX แจ้งเตือน” เหมือนกัน) แล้วสร้างความสับสน
  2. เส้นทาง email เฟสหน้าไม่ใช่การเพิ่ม subscription บน topic นี้ แต่คือ ใช้ fx-email-event ที่มีอยู่แล้ว + เพิ่ม template key ใหม่ (FxEmailDispatcher route ด้วย templateKey อยู่แล้ว)
flowchart LR
    SRC["FxOrchestrator<br/>FxOutstandingNotificationScheduler"]
    T1["topic fx-inapp-event<br/>sub FxInApp-Sub<br/>🆕 เฟสนี้"]
    T2["topic fx-email-event<br/>sub FxEmail-Sub<br/>✅ มีอยู่แล้ว (31/07)"]
    SRC --> T1
    SRC -.->|"เฟสถัดไป: publish ซ้ำอีกใบ<br/>ด้วย templateKey ของ outstanding"| T2
    T1 --> D1["FxInAppNotificationDispatcher<br/>→ InAppNotifications + SignalR"]
    T2 -.-> D2["FxEmailDispatcher (มีอยู่แล้ว)<br/>→ ITemplateEmailService → ANE"]
    style T2 stroke-dasharray: 5 5
    style D2 stroke-dasharray: 5 5

การเพิ่ม email = ต้นทาง publish อีกใบไปที่ fx-email-event ด้วย templateKey ใหม่ + เพิ่ม row template — ไม่ต้องเขียน consumer ใหม่เลย เพราะ FxEmailEventConsumer/FxEmailDispatcher มีอยู่แล้วและ route ด้วย templateKey

นี่คือเหตุผลที่ message ของเฟสนี้ต้องพก ข้อมูลสัญญา ไม่ใช่ ข้อความสำเร็จรูป — เฟส email จะใช้ข้อมูลชุดเดียวกันประกอบเป็นอีเมลได้ทันที


8. Prerequisite นอก 3 repo — ต้องยืนยันก่อนเริ่ม (STOP gate)

Owner จำกัด scope ไว้ที่ 3 repo แต่สายข้อมูลจริงพาดผ่านของนอก scope 2 จุด แผนนี้ไม่วางงานให้ 2 จุดนั้น แต่ประกาศเป็น gate ที่ถ้ายังไม่เขียว งาน implement จะรันไม่ผ่าน

Gateเรื่องถ้ายังไม่พร้อมจะเป็นยังไงตรวจยังไง
G-1POST api/thirdpartyfx-service/v1/contracts/outstanding/near-expiry ที่ ThirdPartyFX และ POST /api/v1/fx/forward/contracts/outstanding/near-expiry ที่ FX_400🔴 retry storm เงียบ ๆ — ดูกล่องด้านล่างยิงจริงจาก dev ด้วย custCode จริง 1 ตัว ก่อนเริ่ม task ใด ๆ
G-2topic fx-inapp-event + subscription ที่ ASB ทุก env (Backend_Iac) พร้อมชื่อที่เคาะแล้ว + dup-detection window + MaxDeliveryCountpublisher โยน error ตอน CreateSender/ส่ง หรือ consumer หา sub ไม่เจอแล้วตายยืนยันกับทีม infra + ตรวจ inventory ก่อน; ค่าที่ตั้งตอนสร้าง แก้ทีหลังไม่ได้
G-3config key ใหม่ (ServiceBus:FxInAppTopicName ฝั่ง FX) ต้องไปเพิ่มที่ Backend_Iac/config/{service}/{env}/appsettings.json ครบทุก envCI มี check-config-parity.py ที่ทำ build FAIL ถ้า IaC ขาด key ที่ service มี — และ CI overlay ทับ appsettings.{ENV}.json แบบ full-file replace ตอน build image ดังนั้นแก้ใน repo อย่างเดียว = ไม่มีผลบน clusterเพิ่มพร้อมกันทั้ง 2 ที่ในรอบเดียว
G-3b🔴 parity gate ของ FxOrchestrator dev แดงอยู่แล้ววันนี้ — ยืนยันด้วยการรันจริง ไม่ใช่ข้อสงสัยถ้าไม่ปิดก่อน งาน T-08 จะไปเพิ่ม key ใหม่ทับบน gate ที่ fail อยู่แล้ว และจะแยกไม่ออกว่าพังเพราะของเก่าหรือของใหม่ดูกล่องล่าง — รัน check-config-parity.py แล้วเติม key ที่ขาดให้ครบก่อน
G-4เส้นทางเครือข่ายที่ deploy จริงใช้ cluster-internal DNS ไม่ใช่ APIMBackend_Iac/config/fxorchestrator-service/dev/appsettings.json ตั้ง UserApi.BaseUrl = http://user-service.superappdev.svc.cluster.local และ FxThirdPartyApi.BaseUrl = http://thirdpartyfx-service.superappdev.svc.cluster.local:5001 (ค่า gateway-dev.exim.go.th อยู่แค่ใน base appsettings.json ของ repo ซึ่งถูก env file ทับตอน deploy)ถ้าไปทดสอบผ่าน APIM แล้วผ่าน จะสรุปผิดว่าพร้อม ทั้งที่เส้นทางจริงบน cluster เป็นอีกเส้นทดสอบเส้น cluster DNS ที่ config จริงใช้ · การยิงผ่าน gateway-dev.exim.go.th ใช้ได้เฉพาะตอน dev บนเครื่องตัวเอง

🔴 G-3b ขยายความ — หลักฐานจริง ไม่ใช่ข้อสงสัย

ผลการรันจริง ณ 06/08/2026 (รันคู่ env ให้ตรงกัน — service appsettings.{ENV}.json เทียบกับ IaC {env}/appsettings.json):

S=Backend_Iac/pipelines/scripts/check-config-parity.py

python $S Backend_FxOrchestratorService/src/FxOrchestrator01.API/appsettings.Development.json \
         Backend_Iac/config/fxorchestrator-service/dev/appsettings.json    # → exit 1, ขาด 8 key

python $S Backend_FxOrchestratorService/src/FxOrchestrator01.API/appsettings.SIT.json \
         Backend_Iac/config/fxorchestrator-service/sit/appsettings.json    # → exit 0, ผ่าน

8 key ที่ IaC dev ขาด: FxContractApi.ResetHolidaySync · FxThirdPartyApi.GetNearExpiryOutstandingContracts · HolidaySync.DailyCheck.MinDaysSinceFirstOfYear · ScheduledJobs.Jobs.HolidayDailyCheckSyncJob.{CronExpression,Enabled,RunImmediatelyOnStartup,TimeZone} · UserApi.GetCompanyCustCodes

  • 🔴 dev แดงอยู่แล้ว — ข้อความจาก script เองคือ “IaC overlay would STRIP keys the service config defines (runtime breakage risk)” แปลว่าค่าที่ service คาดว่าจะมี จะหายไปตอน overlay
  • ⚠️ guard เทียบกับ env file (appsettings.{ENV}.json) ไม่ใช่ base appsettings.json — ข้อโต้แย้งว่า “base รอด overlay จึงไม่เป็นไร” ใช้กับ gate นี้ไม่ได้

🔴 และของแถมที่ใหญ่กว่า — sit/uat ไม่มี guard คุ้มกันเลย และ appsettings.SIT.json ตายบน cluster

Backend_Iac/pipelines/templates/overlay-cloud-config.yml map envDirASPNETCORE_ENVIRONMENT แบบนี้:

case "${{ parameters.envDir }}" in
  dev)  ENV_NAME="Development" ;;
  sit)  ENV_NAME="Sit" ;;      # ← "Sit" ไม่ใช่ "SIT"
  uat)  ENV_NAME="Uat" ;;
esac
DST=".../appsettings.${ENV_NAME}.json"
if [ -f "$DST" ]; then  python3 check-config-parity.py "$DST" "$SRC" || exit 1;  fi
cp "$SRC" "$DST"

FxOrchestrator มีไฟล์ชื่อ appsettings.SIT.json (ตัวใหญ่) แต่ pipeline หา appsettings.Sit.json บน agent ubuntu-latest ซึ่ง case-sensitive

  1. [ -f "$DST" ] เป็นเท็จ → parity guard ไม่เคยรันให้ sit เลย (uat ก็เช่นกัน เพราะไม่มีไฟล์ appsettings.Uat.json)
  2. cp สร้าง appsettings.Sit.json จาก IaC ขึ้นมาใหม่ → นั่นคือไฟล์ที่ .NET โหลดจริง ส่วน appsettings.SIT.json ใน repo ไม่เคยถูกอ่านบน cluster

ผลต่องานนี้: ค่า ServiceBus:FxInAppTopicName ของ sit และ uat ต้องอยู่ที่ IaC เท่านั้น เติมในฝั่ง service ไม่มีผล — และเพราะ publisher throw ตอน startup เมื่อ key หาย (ดู §10 ข้อ 10) ถ้าลืม IaC env ใด service จะไม่ขึ้นใน env นั้น

ผลนอกงานนี้ (ควรแจ้งเจ้าของ repo): ทุกค่าที่ใครก็ตามใส่ไว้ใน appsettings.SIT.json ของ FxOrchestrator ไม่มีผลบน SIT มาตลอด — เป็นหนี้แยกต่างหาก ไม่ได้แก้ในงานนี้

สิ่งที่ต้องทำ: เติม 8 key ข้างบน บวก ServiceBus:FxInAppTopicName ลง Backend_Iac/config/fxorchestrator-service/dev/appsettings.json และเติม key ใหม่ลง sit ด้วย

🔴 G-1 ขยายความ — ทำไมถึงต้องเป็น gate ไม่ใช่แค่ “task ที่ยังไม่เสร็จ”

ถ้าเปิด Enabled=true ก่อนที่ปลายทาง near-expiry จะพร้อม พฤติกรรมจะเป็นแบบนี้:

  1. GetNearExpiryAsync โยน FxThirdPartyApiException (404/405)
  2. exception หลุดออกจาก RunTickAsync ไปถูกจับที่ ExecuteAsync แล้ว LogError เฉย ๆ
  3. บรรทัด cache.SetAsync(MarkerKey(...)) ไม่เคยถูกรัน เพราะอยู่หลังจุดที่ throw
  4. slot ของวันนั้นจึงยังนับว่า “due” ตลอด → tick ถัดไป (ทุก RefreshIntervalSeconds = 15 นาที) ลองใหม่ แล้วพังใหม่ วนทั้งวัน

ผลคือ error log ท่วมโดยไม่มี noti ออกสักใบ และไม่มีอะไรดังพอให้รู้ว่าผิด (ไม่ใช่ crash, pod ยัง healthy) — ต้องรู้ล่วงหน้าว่านี่คืออาการที่คาดไว้ ไม่ใช่บั๊กของโค้ดใหม่ ห้ามเปิด kill switch จนกว่า G-1 จะเขียว


9. ทางเลือกที่พิจารณาแล้วไม่เลือก

ทางเลือกทำไมไม่เลือก
A. ใช้ notification-personal เดิม (แผน v2)Owner ล้มแล้ว (§0.1) และตาราง §5 อธิบายว่าแลกอะไร
B. Business เป็น entity+table จริง พร้อมหน้าจอ admin เปิด-ปิดราย businessราคา: migration ใหม่ใน NS (ซึ่ง chain replay พังอยู่แล้ว) + CRUD + หน้าจอ + seed ทุก env — และไม่ได้ทำให้ 4 เจตนาของ Owner สำเร็จเพิ่มขึ้นเลย เพราะ kill switch มีอยู่แล้วที่ FX Codex (Enabled) และ AppCode แยกได้แล้วที่ read side เปิดทางไว้: ถ้า Owner ต้องการทะเบียน business จริง ให้ทำเป็น story แยก อย่ายัดเข้า SA-1380
C. เรนเดอร์ข้อความที่ต้นทางต่อไป (เก็บ title/subtitle string)ขัดกับที่ Owner สั่งว่าต้นทางส่ง “data ข้อมูลสัญญา” · และทำให้ email เฟสหน้าต้องกลับไปแก้ FxOrchestrator อีกรอบ · และแก้ข้อความต้อง deploy service
D. ให้ NotificationService ไปดึงข้อมูลสัญญาเองNS จะต้องรู้จัก FX_400/ThirdPartyFX/custCode = ลาก FX domain เข้ามาใน NS ผิดทิศ และทำให้ NS พึ่ง AS/400 ตอน consume
E. เพิ่ม sourceService/externalMessageId ให้ PersonalSignalConsumer แล้วใช้ path เดิมแตะ consumer ที่ทุก feature ใช้ร่วมกัน = blast radius ที่ Owner ต้องการหลีกเลี่ยงพอดี · แต่ บันทึกไว้เป็นหนี้ที่ควรแก้แยกต่างหาก เพราะ path เดิมไม่มี DB dedup เลยทุกวันนี้

10. พื้นผิวที่ห้ามเปลี่ยน (blast radius)

งานนี้ต้อง ไม่แตะ ของพวกนี้ — ถ้า diff ไปโดน แปลว่าออกนอก scope:

  1. PersonalSignalConsumer · topic notification-personal · subscription Notification-Personal-Sub
  2. BroadcastSignalConsumer, NotificationEventConsumer, InvitationEventConsumer, FlowStatusNotificationConsumer, WorkflowMakerNotificationConsumer, FxEmailEventConsumer — และ subscription ของทุกตัว
  3. Signature ของ IInAppNotificationService และพฤติกรรมของ SendPersonalAsync (เรียกใช้ ไม่ใช่แก้)
  4. Schema ของ InAppNotificationsห้ามมี migration ใหม่ในงานนี้ ถ้ารู้สึกว่าต้องมี แปลว่าออกนอกแนวทาง §0.3 ให้หยุดแล้วถาม
  5. Query ของ GetByUserIdAsync / GetUnreadCountAsync — ใช้ optional appCode ที่มีแล้ว ห้ามเพิ่ม filter ใหม่
  6. AppHub และ contract ของ group user-{userId}
  7. กลไก lock / slot marker / Codex config ของ FxOutstandingNotificationScheduler — เปลี่ยนเฉพาะ “ส่งอะไรออกไป” ไม่เปลี่ยน “ส่งเมื่อไหร่”
  8. UserService — endpoint companies/cust-codes ใช้ตามเดิม ไม่แก้ (การเปลี่ยน [AllowAnonymous][RequireApiKey] เป็นข้อเสนอแยก ดู implementation plan G-5)

และ 2 กับดัก DI ที่ทำให้ของใหม่ “เงียบไม่ทำงาน” โดยไม่มี error:

  1. NotificationService register consumer ซ้ำ 2 ที่Notification02.Infrastructure/DependencyInjection.cs มี branch Mode B (Entra RBAC, บรรทัด 206-226) และ Mode A (connection string, 227-246) ที่ register ชุด consumer เดียวกัน ทั้งคู่ ลืม branch ใดก็ตาม = consumer ตายเงียบใน mode นั้น (มักไปเจอตอน SIT/UAT ซึ่งใช้คนละ mode กับเครื่อง dev) · หมายเหตุ: มี branch ที่สาม (else, 247-250) ที่ไม่ register consumer เลย — คือกรณีไม่มี ASB config ซึ่งเป็นสถานะปกติของเครื่อง dev ที่ไม่ได้ตั้งค่า อย่าสรุปว่า “โค้ดไม่ทำงาน” จากเครื่องที่ตกอยู่ใน branch นี้
  2. FxOrchestrator ผูก publisher ไว้กับเงื่อนไข ASBIPersonalNotificationPublisher ถูก register เป็นตัวจริงเมื่อมี ServiceBus client เท่านั้น ไม่งั้นได้ NullPersonalNotificationPublisher ที่เงียบสนิท (ดู FxOrchestrator02.Infrastructure/DependencyInjection.cs ช่วง hasAzureServiceBus) publisher ตัวใหม่ต้องเดินตาม pattern เดียวกัน และ ต้องตรวจว่า env ที่จะทดสอบมี ASB จริง ไม่งั้นจะสรุปผิดว่า “โค้ดไม่ทำงาน”

10.1 🔄 Cutover — FX เคยเดินผ่าน notification-personal อยู่แล้ว

ข้อเท็จจริงที่ §10 ข้อ 1 ปิดบังไว้: วันนี้ FX in-app ทำงานอยู่แล้ว ผ่านเส้นเดิม (scheduler → AsbPersonalNotificationPublishernotification-personalPersonalSignalConsumerSendPersonalAsync → row + SignalR) งานนี้ไม่ได้ “เพิ่มของใหม่” เฉย ๆ แต่ ย้าย traffic ออกจากเส้นเดิม — §10 ข้อ 1 หมายถึงห้ามแก้ไฟล์ของเส้นเดิม ไม่ได้แปลว่าไม่มีอะไรเปลี่ยนพฤติกรรม

ประเด็นรายละเอียด
ลำดับการตัดปิด Enabled ที่ FX Codex → deploy ทั้ง NS และ FX → ยืนยัน topic+subscription มีจริง (G-2) → เปิด Enabled · ถ้าเปิดก่อน NS พร้อม message จะค้างใน topic (ไม่หาย แต่ก็ไม่มีใครกิน)
rollbackrevert 1 บรรทัดใน FxOrchestrator02.Infrastructure/DependencyInjection.cs กลับไป publisher เดิม แล้ว deploy — เส้นเดิมยังอยู่ครบ ไม่ได้ถูกลบฝั่ง NS
🔴 ความเสี่ยงซ้ำช่วงตัดrow ที่เขียนผ่านเส้นเดิมมี ExternalMessageId = NULL (เพราะ PersonalSignalConsumer ไม่ส่งค่านี้) และ unique index มี HasFilter("ExternalMessageId IS NOT NULL")dedup กันซ้ำข้ามเส้นทางไม่ได้ ถ้า slot เดียวกันของวันเดียวกันถูกส่งทั้งเส้นเก่าและเส้นใหม่ ผู้ใช้จะเห็น 2 ใบ · วิธีกัน: อย่าเปิด Enabled ซ้ำ slot ที่วันนั้นส่งไปแล้ว หรือรอข้ามวัน
ของที่ไม่กระทบfeature อื่นบน notification-personal ไม่เปลี่ยนเลย — ทั้ง topic, consumer และ subscription ยังทำงานเหมือนเดิมทุกอย่าง

11. ข้อสมมติที่ยังไม่ verify (อย่าถือเป็นข้อเท็จจริง)

#ข้อสมมติตรวจยังไง
A-1ASB namespace ของแต่ละ env รองรับการเพิ่ม topic ใหม่ได้ทันที (ไม่ติด SKU limit)ถาม infra + ดู inventory — fx-email-channel เคยติดประเด็นนี้ค้างไว้
A-21 custCode = 1 company (ถ้าซ้ำ โค้ดต้นทาง union userIds ให้แล้ว)query DB จริงว่ามี custCode ซ้ำข้าม company ไหม
A-3ปริมาณ fan-out ต่อ slot อยู่ในระดับที่ topic เดี่ยวรับไหวนับ จำนวนสัญญาใกล้ครบ × user เฉลี่ยต่อบริษัท จาก dev/uat ก่อน go-live
A-4ผู้รับที่ถูกต้องคือ “user active ทุกคนในบริษัทที่ถือสัญญา” ไม่ใช่เฉพาะคนที่สร้างสัญญาเป็น decision D2 ของแผน v2 ที่สืบทอดมา — ถ้า business ไม่ยืนยัน จำนวน noti จะผิดคาดมาก
A-5NotificationType="Warning" เป็นค่าที่ FE เรนเดอร์ถูกฝั่ง backend ยืนยันแล้วว่ารองรับ — NotificationTypes.All (Notification04.Domain/Constants/NotificationTypes.cs:10) มี Info, Warning, Success, Error และ GetNotificationsQueryValidator.cs:19-21 บังคับ whitelist นี้ที่ read side อยู่แล้ว ที่ยังไม่ verify คือฝั่ง FE เท่านั้น ว่ามี icon/สีสำหรับ Warning — ถามทีม FE (G-7)
A-6ASB MessageId ที่ต้นทางตั้งอยู่แล้ว มีผลเป็น dedup จริงก็ต่อเมื่อเปิด duplicate detection บน topicค่าเหล่านี้ แก้ทีหลังไม่ได้ ต้องตั้งตอนสร้าง topic — ยืนยันกับ infra ตอน G-2 · อย่าพึ่ง ASB อย่างเดียว ตัว dedup ที่เชื่อได้คือ unique index ที่ DB
A-7ผู้ส่งจริงที่ทำให้ message ไหลได้คือ FxOrchestrator เท่านั้นfx-email-channel เคยเจอว่า publisher ที่คิดว่ามี จริง ๆ grep ไม่เจอ — ยืนยันว่า FxOutstandingNotificationPublisher ถูก register จริงในทั้ง 2 โหมด ASB (ดู §10 ข้อ 9)

12. เอกสารที่เกี่ยวข้อง

  • FX_OUTSTANDING_NOTIFICATION_IMPLEMENTATION_PLAN.md — งานเรียงตาม dependency + test coverage
  • PLAN_FX_EMAIL_CHANNEL.md · SPEC_FX_EMAIL_CHANNEL_PUBLISHER.md — pattern พี่น้องที่ลอกโครงมา
  • IN_APP_NOTIFICATION_API_SPEC.md · SA-1473_CODE_REVIEW.md — read-side ที่งานนี้ใช้ต่อ
  • NOTIFICATIONSERVICE_REPO_KNOWLEDGE.md — gotcha ของ repo (migration replay, OTP email dev, worktrees)
  • Jira SA-1380 (AC1 in-app = งานนี้ · AC2 highlight หน้า Outstanding = FE นอก scope · AC3 email = เฟสหน้า) · SA-1556 (ตั้งค่าจำนวนวันล่วงหน้า — ถูกครอบคลุมแล้วบางส่วน ด้วย ExpireDate ใน FX Codex config ที่แก้ได้โดยไม่ deploy ขาดแค่หน้าจอตั้งค่า)