# NOTIFY_CONFIG — Per-Tenant 通知設定 Design Spec

| 項目 | 內容 |
|---|---|
| Date | 2026-04-27 |
| Author | raymond + Claude |
| Status | Draft → 待 user 確認後進實作計劃 |
| 範圍 | Backend + Frontend，僅 NOTIFY_CONFIG 一個子系統 |
| 不在範圍 | SECURITY_CONFIG / DRIVE_CONFIG / AI_PROVIDER / BRANDING（各自獨立 spec 後續處理）|
| Revisions | v1 (initial) → v2 (thread-local / SQL schema / capability decorator absence / caller list) → v3 (round 2 reviewer: batch suppress 真實呼叫鏈、test endpoint admin role、ui_routes idempotent、envelope 對齊 codebase、刪除 ContextVar 方案 B)|

---

## 1. Why — 為什麼要做

### 1.1 驅動力

1. **客戶反映 / 多租戶痛點（Driver A）** — `.env` 裡 `TELEGRAM_BOT_TOKEN` / `DISCORD_WEBHOOK_URL` 為單租戶設計，多客戶共用部署時 A 客戶的審計通知會打到 B 客戶的頻道。
2. **Multi-tenant SaaS roadmap（Driver B）** — `ENABLE_MULTI_TENANT=true` 已啟用，但通知層仍走 `os.getenv`，是 multi-tenant 設計漏洞。

### 1.2 額外效益

- 消除 onboarding 摩擦
- 將 secret 從 `.env` 移至 `system_configs` + 既有 `HIDDEN_SECRET` 隱藏機制
- 建立「`.env` → `system_configs` UI」的 SOP

### 1.3 既有 infra 已就位

- `system_configs` 表 + `TenantScopedMixinModel` + RLS 自動處理租戶隔離
- `HIDDEN_SECRET` + `changePwd` 慣例（`api/system_config/routes/system_config_route.py:18`）
- 4 個既有設定頁範本可抄

---

## 2. Scope

### 2.1 In Scope

- 後端
  - `system_configs` 兩筆 row：`NOTIFY_CONFIG/DISCORD`、`NOTIFY_CONFIG/TELEGRAM`
  - `app/notification/service/notification_service.py` 改寫（移除 `os.getenv`，改讀 `SystemConfigService`，且支援 caller 預解析 cfg 後傳入以避開 thread-local 問題）
  - 新模組 `api/notify_config/` + `app/notify_config/` 提供 test 端點
  - 5 個 caller 清理（移除 `SEND_NOTIFY_MODULES` + `os.environ` 騷操作 — 完整清單見 §4.2）
  - 新增 error code
  - logger.warning 加在「靜默 skip」路徑（保留可觀測性）
- 前端
  - 新 view `src/views/notify-config/NotifyConfigForm.vue`
  - i18n / router / api.js 對應更新
- Migration：`ui_routes` / `capabilities` / `route_capabilities` / `role_capabilities` 四表的 INSERT，依既有 `2026-04-27-add-audit-and-cloud-integration-permissions.sql` 慣例與實際 DB schema
- 測試：pytest unit + 整合 + 手動驗收

### 2.2 Out of Scope（YAGNI / 留紀錄）

- ❌ MAIL channel 加入 NOTIFY_CONFIG（既有 `SMTP/*` 已 per-tenant、預設都要開、SMTP 設定頁已有測試功能）
- ❌ `system_config_route` 缺 capability check 的技術債
- ❌ 通知歷史紀錄頁
- ❌ Per-user 通知偏好
- ❌ Channel template 客製
- ❌ 多個 Discord / Telegram channel
- ❌ Test endpoint 打真網路的 contract test
- ❌ Load test
- ❌ E2E 測試（屬 `compliance-manager-test/`）
- ❌ **既有 SMTP/* 在 thread fan-out 下的 thread-local user_context 問題**（與本次相同模式但本就在 multi-tenant 場景下有相同 bug — 單獨 PR 處理，本次只新增 channel 不擴大破口）
- ❌ Notification dispatcher refactor

---

## 3. Data Schema

### 3.1 兩筆 row（per-tenant）

```
NOTIFY_CONFIG / DISCORD
  value: {"enabled": bool, "secret": "<webhook_url>"}

NOTIFY_CONFIG / TELEGRAM
  value: {"enabled": bool, "chat_id": "...", "secret": "<bot_token>"}
```

### 3.2 設計重點

- `secret` 沿用既有命名慣例
- Discord webhook URL 視為 secret（含可寄訊息的 token）
- Telegram `chat_id` 公開可見，獨立放外層

### 3.3 Fallback

依「比照 .env 的做法」：缺 row → 該 channel 直接 skip。**不寫 seed data、不寫 `tenant_id IS NULL` 全系統預設**（避免跨 tenant 共用通道）。

### 3.4 Secret 隱藏

`api/system_config/routes/system_config_route.py:18` 的 `HIDDEN_SECRET` 名單加 `'NOTIFY_CONFIG'`。

⚠️ **實作前 grep 確認沒有其他繞過 HIDDEN_SECRET 的讀取路徑**：
```bash
grep -rE "system_config.*get|SystemConfig.*from_entity" \
  api/ app/ --include="*.py"
```
若發現有額外 endpoint 直接序列化 `value` JSONB 出去（未過 `HIDDEN_SECRET`），須一併補 hide 邏輯或在那條路徑加 NOTIFY_CONFIG 排除。

---

## 4. Service Layer

### 4.1 `app/notification/service/notification_service.py` — 重寫

#### 既有問題

```python
class NotificationService:
    def __init__(self, system_config_service):
        # ❌ os.getenv 在 __init__ 跑一次後 cache 在 instance 上
        self.telegram_bot_token = os.getenv("TELEGRAM_BOT_TOKEN", None)
        self.telegram_chat_id = os.getenv("TELEGRAM_CHAT_ID", None)
        self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", None)
        self.system_config_service = system_config_service
```

#### Thread-local 重要前提

`auth_context` 用 `contextvars.ContextVar`（`jedi-common/jedi_common/session/auth/auth_context.py:7`）。`threading.Thread` **預設不繼承 ContextVar**——子 thread `get_user_context()` 回 `None`。

→ 既有 caller 5 處全部用 `threading.Thread(target=notification_service.send_xxx_notification).start()` fan-out。如果 `send_*` 內部直接從 user_context 讀 tenant config，**子 thread 一律拿到 None**。

#### 解法：caller 在主 thread 預先解析 cfg，作為 thread kwargs 傳入

```python
from jedi_common.session.auth.auth_context import get_user_context
from jedi_common.handler.exception import ServerError, NotFound
from common.code.error_code import ErrorCode

class NotificationService:
    def __init__(self, system_config_service: SystemConfigService):
        self.system_config_service = system_config_service

    def get_channel_config(self, channel: str) -> dict | None:
        """讀當前 tenant 的 NOTIFY_CONFIG/<channel>。
        Caller 必須在 user_context 內呼叫（通常是主 request thread）。
        Thread fan-out 場景：caller 應在主 thread 先呼叫此 method 拿 cfg dict，
        再把 cfg 作為 kwarg 傳入 send_xxx_notification 的 thread target。
        """
        if get_user_context() is None:
            # ⚠️ jedi_common 沒有 InternalServerError 子類，直接用 base ServerError(message, error_code, status_code)
            raise RuntimeError(
                f"NOTIFY_CONFIG read requires user_context (channel={channel})"
            )  # jedi-handler @app.errorhandler(Exception) fallback → 500
        try:
            cfg = self.system_config_service.get_system_config_by_key(
                "NOTIFY_CONFIG", channel
            )
            return cfg.value if cfg else None
        except NotFound:
            return None

    def send_mail_notification(self, to, subject, content, is_html=False):
        """MAIL 不在 NOTIFY_CONFIG 範圍 — 維持既有 SMTP/* 邏輯。
        ⚠️ 既有 SMTP/* 也有相同 thread-local 問題（thread 內 get_system_config_by_key
        會走 super_admin RLS bypass），multi-tenant 下會撈到任意 tenant 的 SMTP — 既存 bug，OOS。
        """
        # ... 既有實作不動

    def send_discord_notification(self, message, cfg: dict | None = None):
        """cfg：caller 在主 thread 預解析後傳入；省略則嘗試從 user_context 讀（同步呼叫）。"""
        if cfg is None:
            cfg = self.get_channel_config("DISCORD")
        if not cfg or not cfg.get("enabled") or not cfg.get("secret"):
            logger.warning(
                "Discord notification skipped: "
                "channel disabled or missing config (tenant_id=%s)",
                self._tenant_id_for_log(),
            )
            return None
        config = DiscordConfigDTO(webhook_url=cfg["secret"])
        Notifier("DISCORD", config.dict()).send_notification(
            DiscordRequestDTO(content=message)
        )

    def send_telegram_notification(self, content, cfg: dict | None = None):
        if cfg is None:
            cfg = self.get_channel_config("TELEGRAM")
        if (not cfg or not cfg.get("enabled")
                or not cfg.get("secret") or not cfg.get("chat_id")):
            logger.warning(
                "Telegram notification skipped: "
                "channel disabled or missing config (tenant_id=%s)",
                self._tenant_id_for_log(),
            )
            return None
        config = TelegramConfigDTO(
            bot_token=cfg["secret"],
            chat_id=cfg["chat_id"],
        )
        Notifier("TELEGRAM", config.dict()).send_notification(
            TelegramRequestDTO(message=content)
        )

    def _tenant_id_for_log(self) -> str:
        """log 用 tenant_id（拿不到回 'unknown'，不 raise）。"""
        try:
            ctx = get_user_context()
            return str(ctx.tenant_id) if ctx else "unknown(thread)"
        except Exception:
            return "unknown"
```

#### 行為變更

| 場景 | 改造前 | 改造後 |
|---|---|---|
| Discord/Telegram 缺設定 | raise `NOTIFICATION_*_CONFIG_NOT_FOUND` | return None **+ logger.warning** |
| 改設定後 | 要重啟 BE | 立刻生效 |
| 多租戶 | 全系統共用 .env 一組 | 各 tenant 獨立 |
| Thread fan-out | 直接從 .env 讀（無 tenant 概念）| caller 主 thread 預解析 cfg、kwarg 傳入 |
| 主 thread 缺 user_context | 不檢查 → 跨租戶 leak | raise `NOTIFICATION_NO_TENANT_CONTEXT` |

→ raise 改 return None+log 是因為 caller 用 `Thread` 平行送，單一 channel 缺設定不該炸事件處理，但要留 log 線索。

### 4.2 5 個 Caller 清理

| 檔案 | 行 | 改動 | cfg 預解析放置點 |
|---|---|---|---|
| `workflow_execution_service.py` `notify_users_batch_assigned` | 1041- | 移除 `os.getenv` + Thread kwargs cfg | user 迴圈前（method 開頭） |
| `workflow_execution_service.py` `notify_user_todo_job` | 1086- | 同上 | `JobStatus.PROCESSING` + `ProjectStatus.IN_PROGRESS` short-circuit 通過後、assignees 迴圈前 |
| `workflow_execution_service.py` `notify_control_reviewers_on_task_complete` | 1162- | 同上 | reviewer filter 完成、確定有對象後 |
| `workflow_execution_service.py` `complete_job` | 572 | 加 `suppress_notify` 參數；`if not suppress_notify: notify_user_todo_job(...)` | — |
| `workflow_execution_service.py` `revert_job` | 635 | 同 `complete_job` 加參數 | — |
| `app/grc/service/project_service.py` | 644 | 同上 fan-out 模式 | method 開頭 |
| `app/task_survey/service/task_survey_service.py` | 444 | 同上；保留 `assignees` 為空 early return | early return 後再預解析 |
| `app/grc/service/job_batch_complete_service.py` | 38-40 / 87-90 | **拔掉 `os.environ["SEND_NOTIFY_MODULES"] = ""` 騷操作**；改傳 `suppress_notify=True` 給 `complete_job` | — |
| `app/grc/service/job_batch_complete_service.py` `_send_batch_summary_notification` | 108- / 116 | 同上 fan-out 模式 | method 開頭 |

#### Caller 改造範例（以 workflow_execution_service.py 1041- 為例）

```python
@transaction
def notify_users_batch_assigned(self, user_job_counts: list) -> bool:
    # 主 thread（仍在 @transaction + user_context 內）預解析三 channel
    discord_cfg = self.notification_service.get_channel_config("DISCORD")
    telegram_cfg = self.notification_service.get_channel_config("TELEGRAM")
    # MAIL 不需要預解析（既有 SMTP/* 邏輯不變）

    system_name = os.getenv("SYSTEM_NAME", "Guidant.AI")
    for user, count in user_job_counts:
        subject = _("batch_task_notification_subject") % {"system": system_name, "count": count}
        message = _("batch_task_notification_content") % {
            "username": user.nickname,
            "count": count,
        }

        # MAIL 永遠送（依比照 .env 的決策）
        threading.Thread(
            target=self.notification_service.send_mail_notification,
            kwargs={
                "to": user.email,
                "subject": subject,
                "content": message,
                "is_html": True,
            },
        ).start()

        if discord_cfg and discord_cfg.get("enabled"):
            threading.Thread(
                target=self.notification_service.send_discord_notification,
                kwargs={
                    "message": message.replace("<br>", "\n"),
                    "cfg": discord_cfg,    # ← 重點：cfg 跨 thread 傳入
                },
            ).start()

        if telegram_cfg and telegram_cfg.get("enabled"):
            threading.Thread(
                target=self.notification_service.send_telegram_notification,
                kwargs={
                    "content": message.replace("<br>", "\n"),
                    "cfg": telegram_cfg,
                },
            ).start()

    return True
```

→ 三個 fan-out method 採同樣模式改寫。

#### 4.2.1 `job_batch_complete_service` 批次 suppress 替代設計

既有行為：批次完成期間用 `os.environ["SEND_NOTIFY_MODULES"] = ""` 暫停逐筆通知，最後統一送一次彙總。

#### 真實呼叫鏈（已 grep 驗證）

```
JobBatchCompleteService.batch_complete:60
  → self._wf_svc.complete_job(...)
       (workflow_execution_service.py)
       └── 內部 line 572 同步呼叫 self.notify_user_todo_job(...)
       └── revert_job:635 也同步呼叫
  + participant/task_assignee_service.py:160 也呼叫 notify_user_todo_job
```

→ batch 那層**直接呼叫 `complete_job`，不是 `notify_user_todo_job`**。Suppress 必須穿透 `complete_job` signature 才能傳達意圖。

#### 改造方案：caller 端 `suppress_notify` 顯式參數

```python
# workflow_execution_service.py 改 complete_job 與 revert_job signature
@transaction
def complete_job(
    self,
    workflow_execution_uid,
    job_id,
    user,
    comment,
    user_nickname,
    suppress_notify: bool = False,   # ← 新增（default False 保留既有行為）
):
    ...
    # line 572 改成
    if not suppress_notify:
        self.notify_user_todo_job(job_execution.id)

@transaction
def revert_job(
    self, workflow_execution_uid, job_id, revert_to_job_id, comment, user, user_nickname,
    suppress_notify: bool = False,
):
    ...
    if not suppress_notify:
        self.notify_user_todo_job(revert_job_execution.id)
```

```python
# job_batch_complete_service.py:38-90 整段拔掉 os.environ 騷操作
# 60 行改成
self._wf_svc.complete_job(
    workflow_execution_uid=wf_uid,
    job_id=template_job_id,
    user=login_name,
    comment=comment,
    user_nickname=nickname,
    suppress_notify=True,    # ← 新增
)
# finally 區塊整段移除（不再需要還原 os.environ）
```

#### 為何不用 ContextVar 方案

ContextVar 方案（在 `send_*` method 內檢查 `notify_suppressed: ContextVar`）**會失效**——`notification_service.send_*` 從 `threading.Thread` 內被呼叫，`threading.Thread` 不繼承 ContextVar，子 thread 內 `notify_suppressed.get()` 永遠是 default `False`。

→ 顯式 `suppress_notify` 參數是唯一可靠解。雖然要動 `complete_job` / `revert_job` signature（兩個 method），但加 default 值 `False` 對既有 caller 透明。

#### `task_assignee_service:160` 那條 caller

不在 batch 範圍（assignee 變更時觸發），不需 suppress 設計。但仍須 §4.2 主表中改成「主 thread 預解析 cfg + thread kwarg」模式。

#### `_send_batch_summary_notification` (job_batch_complete:108-)

`completed_job_uids` 不為空時才送（既有 short-circuit 保留）。內部目前讀 `os.getenv("SEND_NOTIFY_MODULES")`（line 116）—— 改成主 thread 預解析 Discord/Telegram cfg + Thread kwargs 傳入，與 §4.2 caller 範例同模式。

### 4.3 Test Service — `app/notify_config/service/notify_config_test_service.py`

#### 4.3.1 安全考量：admin role check

Test endpoint 接受任意 `value.secret` 並真打外部 HTTP（Discord webhook / Telegram bot）。若只 `@jwt_required` 不擋角色：
- 任何登入使用者可借 prod webhook 送任意 message → 抵觸 `value.secret` 既存資料的隱私
- `changePwd=false` 場景下，viewer 帳號可借管理員設定送訊息給該 tenant 的 Discord 頻道
- 無 rate-limit 下可被當 spam relay → Discord webhook abuse detection

→ **Test endpoint service 層強制檢查 `is_admin`**（既有 `UserContextDTO.is_admin` 屬性，pattern 對齊 `app/cloud_integration/service/google_drive_integration_service.py` 的 `is_admin` 檢查）。

#### 4.3.2 Service 實作

```python
from jedi_common.session.auth.auth_context import get_user_context
from jedi_common.handler.exception import BadRequestError, ForbiddenError, NotFound
from common.code.error_code import ErrorCode

class NotifyConfigTestService:
    """送測試訊息給指定 channel。
    - resolve 階段（@transaction 內）：讀 DB 沿用既存 secret（changePwd=false 時）
    - send 階段（@transaction 外）：對外打 HTTP，避免 hold DB connection
    """

    def __init__(self, system_config_service):
        self.system_config_service = system_config_service

    @transaction
    def _resolve_value(self, channel: str, value: dict, change_pwd: bool) -> dict:
        """純讀 DB：若 changePwd=False，從既存 row 拿 secret 補進 value。"""
        if change_pwd:
            return value
        try:
            existing = self.system_config_service.get_system_config_by_key(
                "NOTIFY_CONFIG", channel
            )
        except NotFound:
            existing = None
        if not existing or not existing.value.get("secret"):
            raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_TEST_NO_EXISTING_SECRET)
        return {**value, "secret": existing.value["secret"]}

    def test_channel(self, channel: str, value: dict, change_pwd: bool = True) -> dict:
        # 1. 驗 admin（業務邏輯層擋，不依賴 capability decorator）
        user = get_user_context()
        if not user or not user.is_admin:
            raise ForbiddenError(ErrorCode.NOTIFY_CHANNEL_TEST_FORBIDDEN)

        # 2. 驗 channel
        if channel not in ("DISCORD", "TELEGRAM"):
            raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_NOT_SUPPORTED)

        # 3. resolve（讀 DB）
        resolved = self._resolve_value(channel, value, change_pwd)

        # 4. send（不 hold DB connection）— 失敗以 4xx envelope 回傳，對齊 codebase 慣例
        try:
            if channel == "DISCORD":
                self._test_discord(resolved)
            else:
                self._test_telegram(resolved)
            return {"channel": channel, "sent_at": datetime.utcnow().isoformat() + "Z"}
        except Exception as e:
            # BadRequestError 不支援 detail kwarg；具體錯誤寫 BE log，前端只顯示固定 message
            logger.exception("Notify channel test failed: channel=%s detail=%s", channel, str(e))
            raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_TEST_FAILED)
```

⚠️ Test service **不改 DB**，只直接呼叫 `Notifier`。失敗改用 `raise BadRequestError`，由 jedi-handler 包成 4xx + `{"error_code", "msg"}` envelope，對齊整個 codebase 慣例（驗證自 `jedi-common/handler/handler.py:18-19`），前端 axios interceptor 自動處理錯誤。

### 4.4 DI

新增 `di_containers/notify_config/containers.py`：

```python
class NotifyConfigContainer(containers.DeclarativeContainer):
    system_config_service = providers.Dependency()
    notify_config_test_service = providers.Singleton(
        NotifyConfigTestService,
        system_config_service=system_config_service,
    )
```

`di_containers/containers.py` 在 `Containers` 內新增：

```python
notify_config_container = providers.Container(
    NotifyConfigContainer,
    system_config_service=system_config_container.system_config_service,
)
```

`config/app_modules.py` 的 `REGISTERED_APPS` 新增 `'notify_config'`（依既有 entry 格式 copy 一行 from `'cloud_integration'`）。

⚠️ `config/di_modules.py` auto-scan pattern 為 `api/**/*_router.py` / `api/**/routes/*_route.py`。新模組路徑 `api/notify_config/routes/notify_config_route.py` 符合，會自動 wire。

### 4.5 Error Codes — 新增 + cleanup 既有 bug

#### 4.5.1 新增

`common/code/error_code.py`：

```python
NOTIFY_CHANNEL_TEST_FAILED              = ("通知測試失敗", "NOTIFY_400001")
NOTIFY_CHANNEL_TEST_NO_EXISTING_SECRET  = ("無既存 secret 可沿用，請填入完整 token", "NOTIFY_400002")
NOTIFY_CHANNEL_NOT_SUPPORTED            = ("不支援的通知頻道", "NOTIFY_400003")
NOTIFY_CHANNEL_TEST_FORBIDDEN           = ("僅系統管理員可執行通知測試", "NOTIFY_403001")
NOTIFY_NO_TENANT_CONTEXT                = ("缺少 tenant context，無法讀取通知設定", "NOTIFY_500001")
```

#### 4.5.2 既有 bug 一併清理

`common/code/error_code.py:41-43` 既有 bug：三筆 NOTIFICATION error code 共用同一 code `NOTIFICATION_404001`：
```python
NOTIFICATION_SMTP_CONFIG_NOT_FOUND      = (..., "NOTIFICATION_404001")  # ❌
NOTIFICATION_DISCORD_CONFIG_NOT_FOUND   = (..., "NOTIFICATION_404001")  # ❌
NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND  = (..., "NOTIFICATION_404001")  # ❌
```

caller 改成靜默 skip 後 Discord / Telegram 兩筆**完全不再被 raise**。本次 PR：
- 直接刪除 `NOTIFICATION_DISCORD_CONFIG_NOT_FOUND` 與 `NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND`
- 保留 `NOTIFICATION_SMTP_CONFIG_NOT_FOUND` 但 code 改為 `NOTIFY_404001`（修共用 bug）
- 同步更新 `config/translations/{en,zh_Hant_TW}/LC_MESSAGES/messages.po` 移除已刪 key、新增新 NOTIFY_* code 翻譯
- pybabel compile

統一前綴 `NOTIFY_*`（含變數名與 code 字串），不留 v1/v2 過渡期混用。

---

## 5. API Endpoints

### 5.1 CRUD — 重用既有 system_config 端點

| Method | URL | 對應 service |
|---|---|---|
| GET | `/system/configs/NOTIFY_CONFIG` | `get_system_config_by_group` |
| GET | `/system/config/NOTIFY_CONFIG/<channel>` | `get_system_config_by_key` |
| PUT | `/system/config/NOTIFY_CONFIG/<channel>` | `update_config_by_group_key` |

→ 後端零工，只改 `HIDDEN_SECRET` 名單一行常數。

### 5.2 新增 test 端點

```
POST /notify-config/<channel>/test
```

`<channel>` ∈ {`DISCORD`, `TELEGRAM`}。MAIL → 400 `NOTIFY_CHANNEL_NOT_SUPPORTED`。

#### Request body

```json
{
  "value": {"enabled": true, "secret": "https://discord.com/api/webhooks/...", "chat_id": "..."},
  "changePwd": true
}
```

#### Response 規範（envelope 對齊整個 codebase 慣例）

⚠️ 主專案實際 envelope 格式為 `{"status": true|false, "data": ...}`（驗證自 `common/util/response_util.py:4-11`）—— 非 `{"code": 1, "data": ...}`。前端 `BaseService.js` 也是讀 `status === true` 判定。

| 結果類型 | HTTP | envelope | 前端 BaseService 行為 |
|---|---|---|---|
| 測試送達 | 200 | `{"status": true, "data": {"channel": "DISCORD", "sent_at": "..."}}` | resolve(`data` 本體） |
| 測試發送失敗（webhook 401 / token 錯）| 400 | jedi-handler 包：`{"status": false, "data": {"message": "...", "error_code": "NOTIFY_400001"}}` | reject |
| 缺少既存 secret（changePwd=false 但無 row）| 400 | 同上 with `NOTIFY_400002` | reject |
| 非 admin | 403 | 同上 with `NOTIFY_403001` | reject |

→ **不採用「200 + data.success=false」雙 flag 模式**——違反 codebase 慣例。

→ Notifier 拋例外時 service 層轉成 `raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_TEST_FAILED)`。`BadRequestError` 簽名僅接 `error_code`（驗證自 `jedi_common/handler/exception.py:22-25`），**不支援 detail kwarg**。具體錯誤訊息（如 webhook 401 detail）改用 `logger.exception(...)` 寫進 server log，前端只顯示固定 message「通知測試失敗」。詳細排錯靠 BE log。

### 5.3 權限模型

#### 5.3.1 CRUD（沿用既有 system_config 家族）

`/system/config/...` PUT 與 GET 沿用既有 `@jwt_required()`。後端目前無 capability decorator（`jedi-auth` / `common/middleware/` 皆無，capability 機制目前純前端）—— 整批 system_config 家族補洞屬 §9.1 技術債，本次不修。

→ 前端透過 `notify_config.read` / `notify_config.update` capability 控制選單與按鈕。

#### 5.3.2 Test Endpoint（強制後端 admin 檢查）

⚠️ Test endpoint 與 CRUD 不同 —— 它**直接觸發外部 HTTP 請求**（送 message 到 webhook / Telegram bot）。為避免被當 spam relay / 跨角色濫用既存 secret，**強制後端檢查 `is_admin`**：

- service 層 `NotifyConfigTestService.test_channel` 第一行 `get_user_context().is_admin` check
- 失敗 raise `ForbiddenError(NOTIFY_CHANNEL_TEST_FORBIDDEN)` → 403
- 前端「測試」按鈕也透過 `notify_config.update` capability 控制 enable（雙重保護）

⚠️ 後端不靠 capability decorator，是因為**沒有現成可用**。Service 層直接讀 `UserContextDTO.is_admin` 是既有模式（pattern 對齊 `app/cloud_integration/service/google_drive_integration_service.py`）。

→ 加這道防護是「**真打外部網路的 endpoint** 必須有後端 role gate」，比一般 CRUD 嚴格。

#### 5.3.3 PUT 跨 tenant 寫入驗證

實作前 grep 確認 `update_config_by_group_key` 是否依賴 RLS 隔離跨 tenant 寫入：
```bash
grep -nE "tenant_id|TenantScopedMixinModel" \
  ~/Projects/Jedicogy/module/jedi-python-package/jedi-system-config/jedi_system_config/infra/repository/system_config_repo_impl.py
```

`SystemConfig` model 已套 `TenantScopedMixinModel` → SQLAlchemy session 的 `tenant_id` 自動注入；RLS policy 擋跨 tenant SELECT/UPDATE。**理論上 RLS 已涵蓋**，但 §8.1.2 仍加整合測試 case 驗證「tenant A user PUT 後 tenant B 看不到」。

### 5.4 模組結構

```
api/notify_config/
├── __init__.py                    # create_module() 返 blueprint
├── routes/
│   └── notify_config_route.py     # POST /notify-config/<channel>/test
└── serializers/
    └── notify_config.py           # NotifyConfigTestRequest / NotifyConfigTestResponse

app/notify_config/
├── __init__.py
└── service/
    └── notify_config_test_service.py

di_containers/notify_config/
└── containers.py
```

---

## 6. Frontend

### 6.1 View

```
src/views/notify-config/
└── NotifyConfigForm.vue
```

範本：抄 `IssueIntegrateConfigForm.vue` 的雙 tab 結構。

#### 結構

```
頁面頂端
└── <Message severity="info">
    Email 通知由 SMTP 設定頁控制 → [前往 SMTP 設定]

TabView
├── Tab "Discord"
│   ├── Checkbox: enabled
│   ├── InputText (mask=password): webhook_url (mapped to value.secret)
│   ├── Hidden flag: changePwd (true only when user types new value)
│   ├── Button: 儲存
│   └── Button: 測試送一則
└── Tab "Telegram"
    ├── Checkbox: enabled
    ├── InputText (mask=password): bot_token (mapped to value.secret)
    ├── InputText: chat_id
    ├── Hidden flag: changePwd
    ├── Button: 儲存
    └── Button: 測試送一則
```

#### Save / 測試 互動規格（明確）

兩按鈕互不依賴，但有 UX 順序提示：

| 動作 | 行為 |
|---|---|
| 點「測試」| 不寫 DB；以當前 form values + `changePwd` 呼叫 POST /notify-config/<channel>/test；axios resolve → toast green / reject (4xx) → toast red 顯示 `error.response.data.msg` |
| 點「儲存」| 標準 PUT /system/config/NOTIFY_CONFIG/<channel>；toast 顯示 envelope `code` 結果 |
| 同時 disable 條件 | 任一在飛行中時兩按鈕都 disable + spinner |
| 是否強制先測試再儲存 | ❌ 不強制（避免 UX 摩擦）|

#### Secret mask 慣例

input `********` 顯示已存在 secret；使用者沒輸入新值時 `changePwd: false`，後端沿用 DB 既存 secret。

### 6.2 API service

`src/config/api/api.js`：
```js
NOTIFY_CONFIG_TEST: getUrl('/notify-config'),  // 後接 /<channel>/test
```

CRUD 重用 `API.SYSTEM_CONFIG`。

### 6.3 Router

`src/config/router/index.js` 在 `/system/*` 群組加：

```js
{
    path: '/system/notify-config',
    name: 'notify-config',
    component: () => import('@/views/notify-config/NotifyConfigForm.vue'),
    meta: { breadcrumb: [{ label: 'notify-config' }], requiresAuth: true }
}
```

放在 ldap-config (170) 與 issue-integrate-config (180) 之間，sort=175。

### 6.4 i18n

#### 前端

新增 `lang.notify_config.*`（zh_Hant_TW + en）：
- `title`, `mail_help`, `discord`, `telegram`
- `enabled`, `webhook_url`, `bot_token`, `chat_id`
- `save_button`, `test_button`, `test_success`, `test_failed`

#### 後端

主專案翻譯實際路徑為 `config/translations/`（非 CLAUDE.md 寫的 `app/translations/`，CLAUDE.md 文字過舊；以實際目錄為準）。

新增 NOTIFY error code 對應翻譯後執行：
```bash
pybabel compile -d config/translations
```
（`.po` → `.mo` 才會生效；CI / Docker image 應已含此步驟，本機 dev 要手動跑）

### 6.5 選單顯示

`AppMenu.vue` 從 `useUserUtil().permissions` 動態撈。後端 migration 加 `notify_config.read` 給 `Administrator` role 後**自動**顯示。

---

## 7. Migration

`scripts/sql/2026-04-27-add-notify-config-permissions.sql`

完全比照 `2026-04-27-add-audit-and-cloud-integration-permissions.sql` 的 schema、命名、idempotent 慣例：

- `capabilities` schema 為 `(id, name, resource_type, action, description)`，UNIQUE on `name`
- `route_capabilities` 含 `requirement` 欄位（`'ALL'` / `'ANY'`，CHECK constraint）
- `role_capabilities` **無** `tenant_id` 欄位（PK `(role_id, capability_id)`）
- `roles` 系統管理員為 `name = 'Administrator' AND tenant_id IS NULL`
- `ui_routes` INSERT 含 `uid gen_random_uuid()`，欄位順序 `(uid, pid, name, url, icon, enable, description, sort)`，無 `root` 欄位
- 命名慣例 `notify_config.read` / `notify_config.update`（dot + snake_case action）

### 7.1 SQL 內容

```sql
-- Date: 2026-04-27
-- 將「通知設定」納入權限管理系統（per-tenant Discord / Telegram channel 設定）
-- 設計依據：docs/features/FR-020-2604-notify-config/design.md
--
-- 執行身份：建議用 cmmgr（DB 擁有者）跑，避免 RLS 擋住系統層級資料：
--   PGPASSWORD='...' psql -h <host> -p 25432 -U cmmgr -d <db> -f <this_file>
-- 若用 cm_app（應用層 user）跑，會被 RLS 擋；SQL 內已加 SET LOCAL app.is_super_admin='t' 旁路
--
-- Idempotent：可重複執行，已存在的資料以 ON CONFLICT DO NOTHING 跳過

BEGIN;

-- ⚠️ 重要：本 migration 會寫入 role_capabilities 給 Administrator 系統角色
-- (roles.tenant_id IS NULL)，未設 super_admin context 會被 RLS 擋住，
-- 導致 INSERT 0 rows。必須先 bypass RLS。
SET LOCAL app.is_super_admin = 't';

-- 1. 新增 notify_config 模組能力點 (2026-04-27)
INSERT INTO public.capabilities (name, resource_type, action, description) VALUES
    ('notify_config.read',   'notify_config', 'read',   '檢視通知設定'),
    ('notify_config.update', 'notify_config', 'update', '修改通知設定（含測試送訊息）')
ON CONFLICT (name) DO NOTHING;

-- 2. 新增 notify-config 路由 (2026-04-27)
-- ⚠️ ui_routes.name 無 UNIQUE constraint（驗證自 jedi_auth/infra/models/ui_route.py），
--     不能用 ON CONFLICT (name)；改用 INSERT ... SELECT WHERE NOT EXISTS 確保 idempotent
INSERT INTO public.ui_routes (uid, pid, name, url, icon, enable, description, sort)
SELECT gen_random_uuid(), 0, 'notify-config', '/system/notify-config', 'pi-bell', 1, '通知設定', 175
WHERE NOT EXISTS (
    SELECT 1 FROM public.ui_routes WHERE name = 'notify-config'
);

-- 3. 綁定 notify-config ↔ capabilities (read=ALL, update=ANY) (2026-04-27)
WITH r AS (SELECT id FROM public.ui_routes WHERE name = 'notify-config'),
     caps AS (
         SELECT id, name FROM public.capabilities
         WHERE resource_type = 'notify_config'
     )
INSERT INTO public.route_capabilities (route_id, capability_id, requirement)
SELECT r.id, caps.id,
       CASE WHEN caps.name LIKE '%.read' THEN 'ALL' ELSE 'ANY' END
FROM r, caps
ON CONFLICT (route_id, capability_id) DO NOTHING;

-- 4. 系統管理員 (Administrator) 角色預設擁有 notify_config.* 全部能力 (2026-04-27)
INSERT INTO public.role_capabilities (role_id, capability_id)
SELECT r.id, c.id
FROM public.roles r
JOIN public.capabilities c ON c.resource_type = 'notify_config'
WHERE r.name = 'Administrator' AND r.tenant_id IS NULL
ON CONFLICT (role_id, capability_id) DO NOTHING;

COMMIT;

-- 驗證查詢（COMMIT 後可跑）：
-- SELECT * FROM public.capabilities WHERE resource_type = 'notify_config';
-- SELECT * FROM public.ui_routes WHERE name = 'notify-config';
-- SELECT rc.*, c.name AS cap_name, r.name AS route_name
--   FROM public.route_capabilities rc
--   JOIN public.capabilities c ON c.id = rc.capability_id
--   JOIN public.ui_routes r ON r.id = rc.route_id
--   WHERE c.resource_type = 'notify_config';
-- SELECT rc.*, c.name AS cap_name
--   FROM public.role_capabilities rc
--   JOIN public.capabilities c ON c.id = rc.capability_id
--   JOIN public.roles r ON r.id = rc.role_id
--   WHERE c.resource_type = 'notify_config' AND r.name = 'Administrator';

-- Rollback (commented):
-- BEGIN;
-- SET LOCAL app.is_super_admin = 't';
-- DELETE FROM public.role_capabilities
--   WHERE capability_id IN (SELECT id FROM public.capabilities WHERE resource_type = 'notify_config');
-- DELETE FROM public.route_capabilities
--   WHERE capability_id IN (SELECT id FROM public.capabilities WHERE resource_type = 'notify_config');
-- DELETE FROM public.capabilities WHERE resource_type = 'notify_config';
-- DELETE FROM public.ui_routes WHERE name = 'notify-config';
-- DELETE FROM public.system_configs WHERE "group" = 'NOTIFY_CONFIG';  -- ⚠️ 會清掉 tenant 已建設定
-- COMMIT;
```

### 7.2 不需要

- ❌ 新建 table → 不需要 `GRANT ON TABLE ... TO cm_app`
- ❌ Seed data → 沒設 = skip 是 §3.3 fallback
- ❌ 新 RLS policy → `system_configs` 既有 policy 涵蓋

---

## 8. Testing Strategy

### 8.1 主專案 pytest

#### 8.1.1 NotificationService 單元測試 — `test/test_notification_service.py`（新增）

| Case | 預期 |
|---|---|
| `send_discord_notification(message)` 無 user_context（主 thread 直呼）| raise `NOTIFICATION_NO_TENANT_CONTEXT` |
| `send_discord_notification(message, cfg=None)` 有 user_context、無 NOTIFY_CONFIG/DISCORD row | return None **+ logger.warning** |
| `send_discord_notification(message, cfg={...enabled:false})` | return None + logger.warning |
| `send_discord_notification(message, cfg={...enabled:true, secret:'webhook'})` | 呼叫 `Notifier('DISCORD', ...)` send |
| `send_discord_notification(message, cfg={...secret 缺})` | return None + logger.warning |
| `send_telegram_notification` 4 個對應 case | 同上模式（缺 chat_id 也要 skip）|
| `send_mail_notification` SMTP/* 缺（既有行為 regression）| raise NotFound |
| `get_channel_config` 無 user_context | raise `NOTIFICATION_NO_TENANT_CONTEXT` |
| `get_channel_config` 有 user_context 且 row 存在 | 回 dict |

Mock 策略：patch `SystemConfigService.get_system_config_by_key` 與 `Notifier.send_notification`，不打真網路。`get_user_context` 用 `monkeypatch` 控制。

#### 8.1.2 NotifyConfig test endpoint 整合測試 — `test/test_notify_config_route.py`（新增）

| Case | 預期 |
|---|---|
| `POST /notify-config/DISCORD/test` 完整 value | 200 + `{"status": true, "data": {"channel", "sent_at"}}` |
| `POST /notify-config/DISCORD/test` Notifier 拋例外 | 400 + `{"error_code": "NOTIFY_400001", "msg": "通知測試失敗"}` |
| `POST /notify-config/DISCORD/test` 無 JWT | 401 |
| `POST /notify-config/MAIL/test` | 400 `NOTIFY_CHANNEL_NOT_SUPPORTED` |
| `POST /notify-config/UNKNOWN/test` | 400 `NOTIFY_CHANNEL_NOT_SUPPORTED` |
| `changePwd=false` 且 DB 無 row | 400 `NOTIFY_CHANNEL_TEST_NO_EXISTING_SECRET` |
| `changePwd=false` 且 DB 有 row | 用 DB secret 測試（mock Notifier 驗證收到 DB 那把 secret）|

⚠️ **不**驗證後端 capability check（§5.3 確認後端目前不擋）。

#### 8.1.3 既有測試回歸

```bash
pytest test/ -k "workflow or oscal_audit or notification or batch_complete or task_survey"
```

caller 5 處改動後既有 workflow / batch_complete / task_survey 測試應照常通過。

### 8.2 手動驗收（changelog 內附）

| 場景 | 預期 |
|---|---|
| dev 登入 blsadmin → 進「通知設定」頁 → 兩 tab | 顯示 Discord / Telegram |
| 填 Discord webhook URL + 「測試」 | Discord 頻道收到 test 訊息 |
| webhook URL 填錯 → 「測試」 | toast 顯示失敗 + detail |
| 儲存後重新整理 → secret 顯示 `********` | secret 不外洩 |
| 觸發既有 workflow 通知事件（如 batch assign）→ Discord 真的收到 | ✅ |
| `enabled=false` → 觸發事件 → Discord 不發 | ✅ + log line `Discord notification skipped` |
| 用 tenant B 帳號（不同 tenant 的不同使用者帳號 — **不是改 X-Tenant-ID header**，per memory）登入後重做設定 + 測試 → tenant A 的 webhook 不會被打到 | RLS 隔離有效 |
| `job_batch_complete_service` 批次完成 → 期間不送逐筆通知 → 完成後彙總一次 | suppress 機制有效（取決於 §4.2.1 採方案 A 或 B）|

### 8.3 Verification before claiming done

```bash
pytest test/test_notification_service.py test/test_notify_config_route.py -v
pytest test/ -k "workflow or batch_complete or task_survey or oscal_audit"  # 既有回歸
```

pytest output 摘要貼進 changelog。

---

## 9. Known Issues / 不修

### 9.1 `system_config_route` + 全家族缺 capability check（既有技術債）

`api/system_config/routes/system_config_route.py` 全 method 只 `@jwt_required`，任何登入使用者都能 PUT 任意 group/key。整個 capability 機制目前純粹是前端選單過濾。

→ **這次不修**（範圍會爆，影響 SMTP / LDAP / Storage / Issue / NOTIFY 全部）。

→ 列為 known issue，留給後續安全強化 PR：
1. `jedi-auth` / `common/middleware/` 加 `@require_capabilities([...])` decorator
2. 各 system_config 家族 route 套上 decorator
3. 補對應整合測試

### 9.2 ~~舊 error codes 殘留~~（本 PR 一併修，已轉到 §4.5.2）

→ 移到本 PR 範圍內處理。

### 9.3 SMTP/* 在 thread fan-out 下的 thread-local 問題

`send_mail_notification → get_notifier → get_system_config_by_key("SMTP", "*")` 在 child thread 跑，`get_user_context()` 回 None → `session_scope` 走 `super_admin='t'` → multi-tenant 下會撈到任意 tenant 的 SMTP row。

→ **既存 bug，與 NOTIFY_CONFIG 同根源**，本次只做 NOTIFY 不擴大破口。SMTP 部分由獨立 PR 一起套用「caller 預解析 cfg + thread kwarg 傳入」模式處理。

---

## 10. 跨租戶通知洩漏防護（背景任務 caveat）

### 10.1 風險

`session_scope()` 在無 user_context 時會把 `app.is_super_admin = 't'` → RLS 完全失效。若未來有背景任務（APScheduler / batch / Celery）發通知：

```python
# ❌ 危險範例
def daily_report_tick():
    with session_scope():  # 無 user_context → super_admin='t' → RLS bypass
        notification_service.send_discord_notification("...")
        # → 撈到任意 tenant 的 webhook → 跨 tenant leak
```

### 10.2 防護機制

`get_channel_config` 強制驗 `get_user_context()`，無 context 時 raise `NOTIFICATION_NO_TENANT_CONTEXT`。

→ 背景任務若要發通知，必須用 `set_user_context(...)` 注入 user context（或 `tenant_context(tenant_id=N)` helper），通知完 clear。

### 10.3 為什麼現在加（不是 YAGNI）

- 加防護成本 ≈ 3 行 code + 1 個 error code（5 分鐘）
- 不加的 bug 是**沉默的**（不報錯、只是訊息打到別家）
- 在 dev 單一 tenant 環境永遠測不出來
- 上 prod 才炸 → 客戶投訴才能 debug
- 本質是 boundary validation 而非 feature → 不在 YAGNI 涵蓋範圍

---

## 11. Decision Log（brainstorming 結論）

| # | 決策 | 選項 | 結論 | 理由 |
|---|---|---|---|---|
| 1 | Scope 範圍 | A: 只 NOTIFY / B: NOTIFY+DRIVE / C: 全 5 個 | A | 風險最低、解鎖 SOP |
| 2 | 驅動力 | A: 客戶痛點 / B: SaaS roadmap / C: 內部整潔 | A+B | 三 channel 都要做且要 scale |
| 3 | 資料結構 | A: 一 group 多 key / B: 巢狀 JSONB / C: 多 group | A | 對齊 ISSUE_INTEGRATE_CONFIG 慣例 |
| 4 | Fallback 行為 | A: NULL row / B: skip / C: warning / D: 混合 | D（後改） | 比照 .env — 缺值 skip，零 seed |
| 5 | Caller 介面 | A: 最小改 / B: dispatcher / C: A+kill switch | A | YAGNI；廢棄 SEND_NOTIFY_MODULES |
| 6 | Test 按鈕 | A: 三 channel / B: 不做 / C: 部分 | A → 後改 2 channel | 客戶痛點高 ROI |
| 7 | MAIL 移除 | 保留 / 移除 | 移除 | SMTP 已 per-tenant；MAIL 預設都要開 |
| 8 | 背景任務 caveat | A1 加 check / A2 註解警告 / C 不處理 | A1 | 3 行成本擋永久風險 |
| 9 | Capabilities 切分 | 三 (read/write/test) / 二 (read/update) | 二 | test 算寫入操作的一環；對齊既有 capabilities 命名（dot + snake_case action）|
| 10 | Seed data | 寫 / 不寫 | 不寫 | fallback 是「沒 row = skip」 |
| 11 | Test endpoint 後端 admin check | 不擋 / @jwt_required only / service 層讀 `is_admin` | service 層讀 `is_admin` | Test endpoint 真打外部 HTTP，不可僅靠前端 hide；後端 decorator 不存在但既有 service-level `is_admin` pattern 可用（cloud_integration 已有先例）|
| 12 | SMTP/* 同根源 thread-local bug | 一併修 / 拆 PR / 不修 | 拆獨立 PR（§9.3） | 範圍會爆（5 caller 全要動 SMTP signature）；本次先把 NOTIFY 的 pattern 跑通，SMTP 套用相同模式即可 |
| 13 | Batch suppress 機制 | A: caller 顯式參數 / B: ContextVar | A | B 在 thread fan-out 下失效（spec §4.1 已闡明 ContextVar 不跨 thread）；A 雖動兩 method signature 但加 default 對 caller 透明 |
| 14 | Test endpoint 失敗回應 | 200+success=false 雙 flag / 4xx + jedi-handler envelope | 4xx + `{"error_code", "msg"}` envelope | 對齊 jedi-handler 既有失敗序列化（`jedi-common/handler/handler.py:18-19`）；前端 axios interceptor 自動處理 |
| 15 | NOTIFICATION_404001 共用 bug | 推 cleanup PR / 本 PR 一併修 | 本 PR 一併修 | 三筆共用 code 中兩筆（Discord/Telegram）本來就不再被 raise，剩一筆（SMTP）改 NOTIFY_404001 順手；分批反而造成 prefix 過渡期混用 |

---

## 12. Future Considerations

實作完成後另開 spec/PR 處理：

- **SECURITY_CONFIG**（MFA per-tenant）
- **DRIVE_CONFIG**（檔案大小 per-tenant）
- **AI_PROVIDER**（BYO key per-tenant）
- **BRANDING**（white-label per-tenant）
- **`system_config_route` 全家族 capability check 補洞**（§9.1）
- **舊 error codes cleanup + 修 NOTIFICATION_404001 共用 bug**（§9.2）
- **SMTP/* thread fan-out 套用同樣 cfg 預解析模式**（§9.3）
- **Notification dispatcher refactor**
- **E2E 測試**（屬 `compliance-manager-test/`）
- **通知歷史紀錄頁**
- **Per-user 通知偏好**
- **Channel template 客製**

---

## Appendix A — 影響檔案清單

### 後端

| 檔案 | 動作 |
|---|---|
| `app/notification/service/notification_service.py` | 重寫（移除 os.getenv、加 cfg kwarg、加 logger.warning、加 user_context check） |
| `app/flow_engine/service/workflow_execution_service.py` 1041-、1086-、1166- | 三處 caller 改用 cfg 預解析 + thread kwarg |
| `app/grc/service/project_service.py` 644 | 同上模式 |
| `app/task_survey/service/task_survey_service.py` 444 | 同上模式 |
| `app/grc/service/job_batch_complete_service.py` 38-40 / 87-90 / 116 | 拔掉 `os.environ` 騷操作 + 改用 service-level suppress flag（§4.2.1）+ caller 改 cfg 預解析 |
| `api/system_config/routes/system_config_route.py:18` | `HIDDEN_SECRET` 加 `'NOTIFY_CONFIG'` |
| `common/code/error_code.py` | 新增 4 個 NOTIFY error code |
| `api/notify_config/__init__.py` | 新增（create_module）|
| `api/notify_config/routes/notify_config_route.py` | 新增 |
| `api/notify_config/serializers/notify_config.py` | 新增 |
| `app/notify_config/__init__.py` | 新增 |
| `app/notify_config/service/notify_config_test_service.py` | 新增 |
| `di_containers/notify_config/containers.py` | 新增 |
| `di_containers/containers.py` | wire NotifyConfigContainer |
| `config/app_modules.py` | `REGISTERED_APPS` 加 `'notify_config'` |
| `config/translations/zh_Hant_TW/LC_MESSAGES/messages.po` | 新增 NOTIFY error code 翻譯 |
| `config/translations/en/LC_MESSAGES/messages.po` | 新增 NOTIFY error code 翻譯 |
| `scripts/sql/2026-04-27-add-notify-config-permissions.sql` | 新增 |
| `test/test_notification_service.py` | 新增 |
| `test/test_notify_config_route.py` | 新增 |

### 前端

| 檔案 | 動作 |
|---|---|
| `src/views/notify-config/NotifyConfigForm.vue` | 新增 |
| `src/config/api/api.js` | 新增 `NOTIFY_CONFIG_TEST` 常數 |
| `src/config/router/index.js` | 新增 `/system/notify-config` 路由 |
| `src/i18n/lang/zh_Hant_TW/...` | 新增 `lang.notify_config.*` |
| `src/i18n/lang/en/...` | 同上 |

### Changelog

- `docs/changelog/2026-04-27-feat-notify-config.md`（feat 類型）

---

## Appendix B — 環境變數變更

實作前必驗：
```bash
grep -rE "TELEGRAM_BOT_TOKEN|DISCORD_WEBHOOK_URL|SEND_NOTIFY_MODULES" \
  config/ app/ api/ common/ --include="*.py"
```
（記憶 `feedback_full_read_no_partial_grep.md` 規範：完整 grep 不要 partial）

確認無漏網 caller 後，`.env` / `.env.example` 拔除：
```
SEND_NOTIFY_MODULES=MAIL,DISCORD,TELEGRAM
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
DISCORD_WEBHOOK_URL=...
```

`.env.example` 加註解說明改至 UI 設定。

`config/config.py` 各 env Config class 也要 grep 確認沒有 hardcode 讀這幾個 env var（目前不太可能但保險）。

---

## Appendix D — Reviewer findings round 2 → spec 對照（v2 → v3）

| Finding | 處理 |
|---|---|
| C-1 batch suppress 呼叫鏈描述錯誤 | §4.2.1 完全重寫：以實際 grep 結果 (`complete_job:572` / `revert_job:635`) 為準，suppress 透過 `complete_job(suppress_notify=True)` signature 穿透 |
| C-2 test endpoint 純 jwt_required 是 abuse vector | §4.3.1 + §5.3.2 強制 service 層 `is_admin` 檢查 + 新增 `NOTIFY_CHANNEL_TEST_FORBIDDEN` 403 code；Decision Log #11 改寫 |
| C-3 ui_routes INSERT 不 idempotent | §7.1 step 2 改用 `INSERT ... SELECT WHERE NOT EXISTS`；註明 `name` 無 UNIQUE constraint |
| M-1 Appendix C m-8 自相矛盾 | Appendix C m-8 改寫 |
| M-2 5 caller 只展開 1 個 | §4.2 表格各 caller 加「cfg 預解析放置點」短註；§4.2.1 expand `_send_batch_summary_notification` 細節；完整 before/after diff 留給 implementation plan 階段 |
| M-3 envelope 違反 codebase 慣例 | §5.2 + §4.3.2 改用 4xx + jedi-handler `{"error_code", "msg"}` envelope；NOTIFY_400001 從 m-4 死碼救活 |
| M-4 ContextVar 方案 B 自打嘴巴 | §4.2.1「為何不用 ContextVar 方案」段明確刪除；Decision Log #13 |
| M-5 PUT 跨 tenant 驗證 | §5.3.3 加驗證步驟 + §8.1.2 加整合測試 case |
| m-1 NOTIFY_400001 死碼 | M-3 處理後變主流 |
| m-3 prefix 不一致 + 共用 code bug | §4.5.2 本 PR 一併修；Decision Log #15 |
| m-9 decision log 缺 v2 取捨 | Decision Log 補 #12 (SMTP OOS) / #13 (suppress 方案) / #14 (envelope) / #15 (cleanup 範圍) |

---

## Appendix C — Reviewer findings round 1 → spec 對照（v1 → v2）

| Finding | 處理 |
|---|---|
| C-1 capability decorator 不存在 | §5.3 改為「後端不擋、純前端」、§8.1.2 移除 403 case、§9.1 描述明確、Decision Log #11 |
| C-2 thread-local user_context | §4.1 加「Thread-local 重要前提」段、`send_*` 加 `cfg` kwarg、§4.2 caller 改造範例展示主 thread 預解析 + thread kwarg 傳入、§9.3 列既有 SMTP 同根源 bug 為 OOS |
| C-3 SQL schema 全錯 | §7 完全重寫對齊既有 `2026-04-27-add-audit-and-cloud-integration-permissions.sql` 與實際 DB schema |
| M-1 caller 漏列 + batch suppress 替代設計 | §4.2 列 5 個 caller、§4.2.1 batch suppress 兩方案、Appendix A sync |
| M-2 靜默 skip 無 log | `send_*` 加 `logger.warning`、§8.1.1 對應測試 case |
| M-3 test endpoint @transaction + UX | §4.3 拆 `_resolve_value` (in transaction) 與 send (out)、§6.1「Save / 測試 互動規格」段 |
| M-4 pybabel compile + path | §6.4 加 `pybabel compile -d config/translations` 並校正 CLAUDE.md 過時路徑 |
| M-5 HIDDEN_SECRET 覆蓋面 | §3.4 加 grep 檢查步驟 |
| M-6 idempotent migration | §7.1 全 INSERT 加 `ON CONFLICT DO NOTHING` |
| m-1 error code prefix 不一致 | §4.5 註明 `NOTIFY_*` 新前綴；舊 `NOTIFICATION_*` cleanup PR 處理（§9.2） |
| m-2 §7.2 實作前必驗 SQL | 移除 — schema 已在 §7.1 對照既有 migration 寫死，不需驗 |
| m-3 REGISTERED_APPS entry 格式 | §4.4 標明「copy from `'cloud_integration'`」 |
| m-4 NOTIFY_400001 unused | §4.5 保留，未來若 raise 才用 |
| m-5 完整 grep 環境變數 | Appendix B 加 grep 命令 |
| m-6 mail 整合 case | §8.1.1 已有 SMTP 缺 raise 的 regression case |
| m-7 跨租戶測試方法 | §8.2 改為「用不同 tenant 的不同使用者帳號重做」 |
| m-8 cfg=None code 走不到 | 已校正：jedi-system-config app service:38 確認 raise NotFound（不是回 None）；`try/except NotFound: return None` 在 `get_channel_config` 是必要防禦；`cfg.value if cfg else None` 中的 `cfg else None` 分支在 service 層 raise 時走不到（`except NotFound` 已先攔截），確實多餘但保留為防禦性判斷不會出問題 |
| m-9 envelope 響應碼 | §5.2 已重寫：採 4xx + jedi-handler `{"error_code", "msg"}` envelope（v3 修改，並於後續 plan review round 2 校正成功 `{"status", "data"}` / 失敗 `{"error_code", "msg"}` 雙路徑差異）|
