# NOTIFY_CONFIG Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把 Telegram / Discord 通知設定從 `.env` 搬到 per-tenant `system_configs`，並提供前端設定頁 + 後端測試送訊息端點。

**Architecture:** 沿用既有 `jedi-system-config` 套件 + RLS tenant 隔離；channel 級設定（`enabled` / `secret` / `chat_id`）以 `(group=NOTIFY_CONFIG, key=DISCORD|TELEGRAM)` 兩筆 row 儲存；caller 改在主 thread 預解析 cfg 後傳入 `threading.Thread` kwargs（避開 ContextVar 不跨 thread 問題）；批次 suppress 改用 `complete_job(suppress_notify=True)` signature 穿透。

**Tech Stack:** Flask + Flask-RESTful + flask-apispec / SQLAlchemy + PostgreSQL 15 RLS / dependency-injector / jedi-common / jedi-system-config / jedi-notification / Vue 3 + PrimeVue + Vite / pytest.

**Spec reference:** [docs/features/FR-020-2604-notify-config/design.md](./design.md)

**Behavior 規範:**
- 完成每個 Task 不自動 commit；commit 訊息列在每個 Task 末尾，但實際 commit 由 user 確認後執行（CLAUDE.md 規範）
- Phase 末尾跑 `pytest` + grep 驗證 + manual smoke 確認後才推進下一個 Phase
- 任何 `pytest` 失敗、grep 仍找到殘留 `os.getenv("SEND_NOTIFY_MODULES")`、UI 操作異常 → 停下來檢視錯誤，不繼續

**主專案兩個測試目錄**（重要）:
- `test/` — pure unit tests，**沒有** conftest.py，純 mock/patch，pytest 直接跑
- `tests/` — integration tests，有 `tests/conftest.py` 提供 fixtures `app` / `client` / `admin_headers` / `tenant2_headers` / `no_auth_headers` / `mock_captcha` / `mock_notification` / `admin_token`
- 本 plan 一律：service 單元測試放 `test/`、route 整合測試放 `tests/`

**主專案 envelope 格式**（重要 — 已驗證自 source code）:
- 成功（`return_response(True, data)`）：`{"status": true, "data": ...}` (`common/util/response_util.py:4-11`)
- 失敗（jedi-handler `register_error_handlers` 包 ClientError/ServerError）：`{"error_code": "<code_string>", "msg": "<message>"}` (`jedi-common/handler/handler.py:18-19`) —— **無 `status`/`data` 包**
- HTTP status code 4xx/5xx 由 exception 自帶（如 `BadRequestError` → 400）
- 前端 `BaseService.js` 看 `status === true` 判定 success；失敗則 axios 自己看 status code 4xx 進 catch
- spec / plan 內所有「`code:0` / `code=1` / `data.message`」舊字眼皆已修正

**`BadRequestError` 等例外簽名**（驗證自 `jedi_common/handler/exception.py:22-25`）:
- `BadRequestError(error_code: BaseCode)` —— **只接 error_code，不支援 detail / message kwarg**
- 具體錯誤詳情用 `logger.exception(...)` 寫 BE log，不傳到前端

---

## File Structure

### 後端新增

| 檔案 | 責任 |
|---|---|
| `api/notify_config/__init__.py` | `create_module()` 無參數、return blueprint |
| `api/notify_config/routes/__init__.py` | routes package |
| `api/notify_config/routes/notify_config_route.py` | `POST /notify-config/<channel>/test` route |
| `api/notify_config/serializers/__init__.py` | serializers package |
| `api/notify_config/serializers/notify_config.py` | `NotifyConfigTestRequest` / `NotifyConfigTestResponse` schemas |
| `app/notify_config/__init__.py` | 模組空 init |
| `app/notify_config/service/__init__.py` | service package |
| `app/notify_config/service/notify_config_test_service.py` | `NotifyConfigTestService.test_channel(channel, value, change_pwd)` |
| `di_containers/notify_config/__init__.py` | container package |
| `di_containers/notify_config/containers.py` | `NotifyConfigContainer` |
| `scripts/sql/2026-04-27-add-notify-config-permissions.sql` | ui_routes / capabilities / route_capabilities / role_capabilities migration |
| `test/test_notification_service.py` | unit 測試 NotificationService（pure mock/patch，無 conftest）|
| `test/test_notify_config_test_service.py` | unit 測試 NotifyConfigTestService（pure mock/patch）|
| `tests/test_notify_config_route.py` | 整合測試 test endpoint（**放 tests/**，用 `admin_headers`）|
| `tests/test_system_config_hidden_secret.py` | 整合測試 GET 不外洩 NOTIFY_CONFIG secret |
| `docs/changelog/2026-04-27-feat-notify-config.md` | 變更紀錄 |

### 後端修改

| 檔案 | 責任變更 |
|---|---|
| `app/notification/service/notification_service.py` | 重寫：移除 `os.getenv`、加 `get_channel_config(channel)`、`send_*` 加 `cfg` kwarg、加 `logger.warning`、加 user_context 檢查 |
| `app/flow_engine/service/workflow_execution_service.py` | 改 `complete_job` / `revert_job` signature 加 `suppress_notify`；3 處 `notify_*` fan-out 移除 `os.getenv("SEND_NOTIFY_MODULES")` 改主 thread 預解析 cfg |
| `app/grc/service/project_service.py:644` | 移除 `os.getenv` + 主 thread 預解析 |
| `app/task_survey/service/task_survey_service.py:444` | 同上 |
| `app/grc/service/job_batch_complete_service.py:38-90, 116` | 拔掉 `os.environ` 騷操作；改傳 `suppress_notify=True` 給 `complete_job`；line 116 `_send_batch_summary_notification` 移除 `os.getenv` 改預解析 |
| `api/system_config/routes/system_config_route.py:18` | `HIDDEN_SECRET` 加 `'NOTIFY_CONFIG'` |
| `common/code/error_code.py` | 新增 5 個 NOTIFY_* code；刪除 NOTIFICATION_DISCORD/TELEGRAM_CONFIG_NOT_FOUND；NOTIFICATION_SMTP_CONFIG_NOT_FOUND code 改為 NOTIFY_404001 |
| `di_containers/containers.py` | wire `NotifyConfigContainer` |
| `config/app_modules.py` | `REGISTERED_APPS` 加 `'notify_config'` |
| `config/translations/zh_Hant_TW/LC_MESSAGES/messages.po` | 新增 NOTIFY_* 翻譯 + 移除已刪 keys |
| `config/translations/en/LC_MESSAGES/messages.po` | 同上 |
| `.env` / `.env.example` | 移除 SEND_NOTIFY_MODULES / TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / DISCORD_WEBHOOK_URL |

### 前端新增

| 檔案 | 責任 |
|---|---|
| `src/views/notify-config/NotifyConfigForm.vue` | 雙 tab 表單（Discord / Telegram）+ 測試按鈕 + SMTP 提示 |

### 前端修改 / 新增

| 檔案 | 變更 |
|---|---|
| `src/config/api/api.js` | 新增 `NOTIFY_CONFIG_TEST` 常數 |
| `src/config/router/index.js` | 新增 `/system/notify-config` 路由（sort=175） |
| `src/config/locales/i18n/zh-tw/notify-config.json` | 新增（per-page namespace JSON）|
| `src/config/locales/i18n/en/notify-config.json` | 同上（en 翻譯）|
| `src/config/locales/i18n/zh-cn/notify-config.json` | 同上（zh-cn 翻譯，依既有 locale 數量補齊）|
| `src/config/locales/i18n/zh-tw/common.json` | 若 `save_failed` 不存在則新增 |

---

## Phase 0 — 前置驗證 (~30 分鐘)

> 目的：把 spec 標註「實作前必驗」的事項全部跑完，避免後續寫到一半發現假設錯誤。

### Task 0.1: 驗證 DB schema

**Files:** N/A（純 DB query 驗證）

- [ ] **Step 1: 確認 capabilities / role_capabilities / route_capabilities / roles schema**

```bash
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg <<'SQL'
\d public.capabilities
\d public.role_capabilities
\d public.route_capabilities
\d public.ui_routes
SELECT id, name, tenant_id FROM public.roles WHERE name = 'Administrator' AND tenant_id IS NULL;
SQL
```

Expected:
- `capabilities`: `(id, name, resource_type, action, description)`，UNIQUE on `name`
- `role_capabilities`: PK `(role_id, capability_id)`，**無** `tenant_id`
- `route_capabilities`: PK `(route_id, capability_id)`，含 `requirement` 欄位（CHECK in `('ALL','ANY')`）
- `ui_routes`: 含 `(id, uid, pid, name, url, icon, enable, description, sort)`，**`name` 無 UNIQUE**
- Administrator role 存在且 tenant_id IS NULL

→ 若 schema 有差異，停下來反映給 spec author 修 spec。

### Task 0.2: 驗證 caller 完整列表

**Files:** N/A（grep）

- [ ] **Step 1: grep 確認所有 caller**

```bash
grep -rnE "SEND_NOTIFY_MODULES|TELEGRAM_BOT_TOKEN|DISCORD_WEBHOOK_URL" \
  app/ api/ common/ config/ tests/ scripts/ --include="*.py"
```

Expected 結果應該完全對應 spec §4.2 表格列出的 5 個 caller，外加 `app/notification/service/notification_service.py` 自身。若多出來新檔案，新增到 plan 對應 Task。

- [ ] **Step 2: grep `notify_user_todo_job` / `complete_job` / `revert_job` 呼叫鏈**

```bash
grep -rnE "\.complete_job\(|\.revert_job\(|notify_user_todo_job\(" \
  app/ api/ --include="*.py" | grep -v "def "
```

Expected：確認 `complete_job` / `revert_job` 從哪些地方被呼叫。

- [ ] **Step 2b: 對每個 caller 檢查 positional vs kwargs 呼叫風格**

```bash
# 對每個 grep 結果手動確認
sed -n '<line>,+5p' <file>
```

→ 若 caller 用 **positional** 呼叫 `complete_job` 把所有參數帶進去（罕見但可能），加 `suppress_notify` 在最末位 default=False 不會破壞既有呼叫；但若 caller 用 `**kwargs` 動態帶入，需確認 schema。實際所有 caller 應該都是 `kwargs=` 風格（如 `JobBatchCompleteService:60`）。記下任何 positional caller，列入 Phase 3 Task 3.1 額外處理。

### Task 0.3: 驗證 i18n 路徑與 pybabel 設定

- [ ] **Step 1: 確認 translations 目錄結構**

```bash
ls -R config/translations/
```

Expected: `config/translations/{en,zh_Hant_TW}/LC_MESSAGES/messages.{po,mo}`。若不是這個結構，停下來檢視 babel.cfg。

- [ ] **Step 2: 試跑 pybabel compile**

```bash
pybabel compile -d config/translations
```

Expected: `compiling catalog ... to ...messages.mo` 無錯誤。

### Task 0.4: 確認 RBAC 三層檔案結構

- [ ] **Step 1: 比對既有 cloud-integrations 設定**

```bash
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg <<'SQL'
SELECT * FROM public.capabilities WHERE resource_type = 'cloud_integration';
SELECT * FROM public.ui_routes WHERE name = 'cloud-integrations';
SELECT rc.*, c.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 r.name = 'cloud-integrations';
SELECT rc.*, c.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 = 'cloud_integration' AND r.name = 'Administrator';
SQL
```

Expected: 看到 4 capability + 1 ui_route + 4 route_capability + 4 role_capability。確認 spec §7.1 的 SQL 風格與此一致。

### Task 0.5: 驗證 dev 環境啟動

- [ ] **Step 1: 啟動 BE**

依記憶 `reference_dev_env_setup.md`：
```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
set -a && source .env && set +a
python main_socketio.py
```

Expected: 無 error 啟動於 port 8000，`http://localhost:8000/swagger-ui/` 可開。

- [ ] **Step 2: 跑既有 pytest baseline（兩個目錄都跑）**

```bash
pytest test/ tests/ -x --co -q 2>&1 | tail -30
```

Expected: collect 成功，無 import error。記下既有 pass/fail 數作 baseline。

- [ ] **Step 3: 確認 tests/conftest.py 提供的 fixture**

```bash
grep -E "@pytest.fixture" tests/conftest.py
```

Expected fixture：`app` / `client` / `admin_headers` / `tenant2_headers` / `no_auth_headers` / `admin_token` / `mock_captcha` / `mock_notification`。

⚠️ **沒有 non-admin headers fixture** —— 本 plan 整合測試對「非 admin」case 改用 monkeypatch `get_user_context` 在 service-level unit test 裡驗（不在 route integration test 驗）。

---

## Phase 1 — Error Codes 重整 (~30 分鐘)

### Task 1.1: 新增 NOTIFY_* error codes 並修復 NOTIFICATION_404001 共用 bug

**Files:**
- Modify: `common/code/error_code.py`

- [ ] **Step 1: Read 既有 NOTIFICATION_* codes 位置**

```bash
grep -n "NOTIFICATION_" common/code/error_code.py
```

記下行號範圍。

- [ ] **Step 2: 寫測試（驗 code 唯一性）**

新增 `test/test_error_code_uniqueness.py`（如果還沒有）：

```python
from common.code.error_code import ErrorCode

def test_no_duplicate_codes():
    codes = [v[1] for v in ErrorCode.__dict__.values() if isinstance(v, tuple)]
    assert len(codes) == len(set(codes)), \
        f"Duplicate codes found: {[c for c in codes if codes.count(c) > 1]}"
```

- [ ] **Step 3: Run test (RED)**

```bash
pytest test/test_error_code_uniqueness.py -v
```

Expected: FAIL，列出 NOTIFICATION_404001 重複三次。

- [ ] **Step 4: 修改 `error_code.py`**

- 刪除 `NOTIFICATION_DISCORD_CONFIG_NOT_FOUND` 與 `NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND`
- `NOTIFICATION_SMTP_CONFIG_NOT_FOUND` code 從 `"NOTIFICATION_404001"` 改為 `"NOTIFY_404001"`
- 在 NOTIFICATION 區塊末加：

```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")
```

- [ ] **Step 5: Run test (GREEN)**

```bash
pytest test/test_error_code_uniqueness.py -v
```

Expected: PASS。

- [ ] **Step 6: grep 確認沒有殘留引用已刪 codes**

```bash
grep -rnE "NOTIFICATION_DISCORD_CONFIG_NOT_FOUND|NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND" \
  app/ api/ common/ config/ test/ --include="*.py"
```

Expected: 僅 `app/notification/service/notification_service.py` 仍有引用——這個會在 Phase 2 一併處理（caller 改成靜默 skip 後就不再 raise）。

- [ ] **Step 7: Commit**

```bash
git add common/code/error_code.py test/test_error_code_uniqueness.py
git commit -m "refactor(error-code): unify NOTIFY_* prefix, fix NOTIFICATION_404001 共用 bug"
```

### Task 1.2: 更新 i18n 翻譯（手改 .po）

**Files:**
- Modify: `config/translations/zh_Hant_TW/LC_MESSAGES/messages.po`
- Modify: `config/translations/en/LC_MESSAGES/messages.po`

- [ ] **Step 1: Read 既有 `messages.po` 確認 NOTIFICATION_* keys 位置**

```bash
grep -n "NOTIFICATION_" config/translations/zh_Hant_TW/LC_MESSAGES/messages.po
```

- [ ] **Step 2: 修改 zh_Hant_TW `messages.po`**

刪除：
```
msgid "NOTIFICATION_DISCORD_CONFIG_NOT_FOUND"
msgstr "..."
msgid "NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND"
msgstr "..."
```

新增：
```
msgid "NOTIFY_404001"
msgstr "SMTP 設定不存在"

msgid "NOTIFY_400001"
msgstr "通知測試失敗"

msgid "NOTIFY_400002"
msgstr "無既存 secret 可沿用，請填入完整 token"

msgid "NOTIFY_400003"
msgstr "不支援的通知頻道"

msgid "NOTIFY_403001"
msgstr "僅系統管理員可執行通知測試"

msgid "NOTIFY_500001"
msgstr "缺少 tenant context，無法讀取通知設定"
```

- [ ] **Step 3: 修改 en `messages.po`**

同上，但 `msgstr` 改為英文。

- [ ] **Step 4: pybabel compile**

```bash
pybabel compile -d config/translations
```

Expected: `compiling catalog ... messages.mo` 兩個 locale 都成功。

- [ ] **Step 5: Commit（只 commit `.po`，不 commit `.mo`）**

```bash
# 確認 .gitignore 是否排除 .mo
grep -E "\.mo$" .gitignore || echo "WARN: .mo not gitignored"

git add config/translations/**/*.po
git commit -m "i18n(notify-config): add NOTIFY_* translations, remove deprecated NOTIFICATION_* keys"
```

⚠️ `.mo` 是編譯產物，慣例不入 git。若 `.gitignore` 沒排除，先補規則或這次手動排除。

---

## Phase 2 — NotificationService 改造 (~3 小時)

### Task 2.1: 寫 NotificationService 改造後的單元測試

**Files:**
- Create: `test/test_notification_service.py`

- [ ] **Step 1: Write failing tests**

```python
# test/test_notification_service.py
from unittest.mock import MagicMock, patch
import pytest
from jedi_common.handler.exception import ServerError, NotFound
from common.code.error_code import ErrorCode


@pytest.fixture
def mock_user_context():
    user = MagicMock()
    user.tenant_id = 102
    user.is_admin = True
    return user


@pytest.fixture
def system_config_service():
    return MagicMock()


@pytest.fixture
def notification_service(system_config_service):
    from app.notification.service.notification_service import NotificationService
    return NotificationService(system_config_service)


# ─── get_channel_config ───
class TestGetChannelConfig:
    def test_no_user_context_raises(self, notification_service):
        # ⚠️ ClientError/ServerError 屬性是 .error_code 不是 .code
        with patch("app.notification.service.notification_service.get_user_context", return_value=None):
            with pytest.raises(ServerError) as exc:
                notification_service.get_channel_config("DISCORD")
            assert exc.value.error_code == "NOTIFY_500001"

    def test_with_context_and_existing_row(
        self, notification_service, system_config_service, mock_user_context
    ):
        cfg_dto = MagicMock(value={"enabled": True, "secret": "webhook"})
        system_config_service.get_system_config_by_key.return_value = cfg_dto
        with patch("app.notification.service.notification_service.get_user_context", return_value=mock_user_context):
            result = notification_service.get_channel_config("DISCORD")
        assert result == {"enabled": True, "secret": "webhook"}

    def test_with_context_and_no_row(
        self, notification_service, system_config_service, mock_user_context
    ):
        system_config_service.get_system_config_by_key.side_effect = NotFound(
            ErrorCode.NOTIFY_404001
        )
        with patch("app.notification.service.notification_service.get_user_context", return_value=mock_user_context):
            result = notification_service.get_channel_config("DISCORD")
        assert result is None


# ─── send_discord_notification ───
class TestSendDiscord:
    def test_no_cfg_no_user_context_raises(self, notification_service):
        with patch("app.notification.service.notification_service.get_user_context", return_value=None):
            with pytest.raises(ServerError):
                notification_service.send_discord_notification("hello")

    def test_with_cfg_disabled_skips(self, notification_service, caplog):
        cfg = {"enabled": False, "secret": "webhook"}
        notification_service.send_discord_notification("hello", cfg=cfg)
        assert "Discord notification skipped" in caplog.text

    def test_with_cfg_no_secret_skips(self, notification_service, caplog):
        cfg = {"enabled": True}
        notification_service.send_discord_notification("hello", cfg=cfg)
        assert "Discord notification skipped" in caplog.text

    def test_with_valid_cfg_calls_notifier(self, notification_service):
        cfg = {"enabled": True, "secret": "https://discord.com/api/webhooks/xxx"}
        with patch("app.notification.service.notification_service.Notifier") as MockNotifier:
            instance = MockNotifier.return_value
            notification_service.send_discord_notification("hello", cfg=cfg)
            MockNotifier.assert_called_once()
            instance.send_notification.assert_called_once()


# ─── send_telegram_notification ───
class TestSendTelegram:
    def test_with_cfg_missing_chat_id_skips(self, notification_service, caplog):
        cfg = {"enabled": True, "secret": "bot_token"}  # 缺 chat_id
        notification_service.send_telegram_notification("hello", cfg=cfg)
        assert "Telegram notification skipped" in caplog.text

    def test_with_valid_cfg_calls_notifier(self, notification_service):
        cfg = {"enabled": True, "secret": "bot_token", "chat_id": "123"}
        with patch("app.notification.service.notification_service.Notifier") as MockNotifier:
            notification_service.send_telegram_notification("hello", cfg=cfg)
            MockNotifier.assert_called_once()


# ─── send_mail_notification regression ───
class TestSendMailRegression:
    def test_smtp_missing_raises_notfound(
        self, notification_service, system_config_service, mock_user_context
    ):
        system_config_service.get_system_config_by_key.side_effect = NotFound(ErrorCode.NOTIFY_404001)
        with patch("app.notification.service.notification_service.get_user_context", return_value=mock_user_context):
            with pytest.raises(NotFound):
                notification_service.send_mail_notification("a@b.com", "subj", "content")
```

- [ ] **Step 2: Run test (RED)**

```bash
pytest test/test_notification_service.py -v
```

Expected: 多數 FAIL（NotificationService 還沒改寫）。

- [ ] **Step 3: Commit failing tests**

```bash
git add test/test_notification_service.py
git commit -m "test(notify-config): add NotificationService unit tests (RED)"
```

### Task 2.2: 改寫 NotificationService

**Files:**
- Modify: `app/notification/service/notification_service.py`

- [ ] **Step 1: Read 既有檔案**

```bash
cat app/notification/service/notification_service.py
```

- [ ] **Step 2: 重寫**

```python
# -*- coding: utf-8 -*-
import logging
from jedi_common.handler.exception import NotFound, ServerError
from jedi_common.session.auth.auth_context import get_user_context

from jedi_system_config.app.service.system_config_service import SystemConfigService
from jedi_notification.app.service.notification_service import NotificationService as Notifier
from jedi_notification.app.dto.notification_request_dto import (
    MailRequestDTO, TelegramRequestDTO, DiscordRequestDTO,
)
from jedi_notification.app.dto.smtp_email_config_dto import SmtpEmailConfigDTO
from jedi_notification.app.dto.discord_config_dto import DiscordConfigDTO
from jedi_notification.app.dto.telegram_config_dto import TelegramConfigDTO

from common.code.error_code import ErrorCode

logger = logging.getLogger(__name__)


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

    def _tenant_id_for_log(self) -> str:
        try:
            ctx = get_user_context()
            return str(ctx.tenant_id) if ctx else "unknown(thread)"
        except Exception:
            return "unknown"

    def get_channel_config(self, channel: str) -> dict | None:
        """讀當前 tenant 的 NOTIFY_CONFIG/<channel>。
        必須在主 thread（有 user_context）呼叫，子 thread 內 ContextVar 不繼承。
        """
        if get_user_context() is None:
            # jedi_common 沒有 InternalServerError 子類，直接用 base ServerError
            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 get_notifier(self):
        """既有 SMTP/* 邏輯，不動。"""
        smtp_config = self.system_config_service.get_system_config_by_key("SMTP", "*")
        if not smtp_config:
            raise NotFound(ErrorCode.NOTIFY_404001)
        smtp_config = smtp_config.value
        smtp_config_dto = SmtpEmailConfigDTO(
            smtp_server=smtp_config.get("host"),
            port=smtp_config.get("port"),
            from_address=smtp_config.get("from"),
            from_name=smtp_config.get("from_name"),
            username=smtp_config.get("user"),
            password=smtp_config.get("secret"),
            tls=smtp_config.get("tls"),
            is_html=smtp_config.get("is_html"),
        )
        return Notifier("SMTP_MAIL", smtp_config_dto.dict())

    def send_mail_notification(self, to, subject, content, is_html=False):
        mail_request_dto = MailRequestDTO(
            to=[to], subject=subject, content=content, is_html=is_html
        )
        return self.get_notifier().send_notification(mail_request_dto)

    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: disabled or missing config (tenant_id=%s)",
                self._tenant_id_for_log(),
            )
            return None
        config = DiscordConfigDTO(webhook_url=cfg["secret"])
        return 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: 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"],
        )
        return Notifier("TELEGRAM", config.dict()).send_notification(
            TelegramRequestDTO(message=content)
        )
```

- [ ] **Step 3: Run test (GREEN)**

```bash
pytest test/test_notification_service.py -v
```

Expected: 全 PASS。若仍有 fail，依錯誤訊息逐個修。

- [ ] **Step 4: Commit**

```bash
git add app/notification/service/notification_service.py
git commit -m "refactor(notification): rewrite NotificationService for per-tenant NOTIFY_CONFIG (GREEN)"
```

---

## Phase 3 — Caller 清理 (~4 小時)

### Task 3.1: 修改 `complete_job` / `revert_job` signature 加 `suppress_notify`

**Files:**
- Modify: `app/flow_engine/service/workflow_execution_service.py:553-, 576-`

- [ ] **Step 1: 寫整合測試（caller 傳 suppress_notify=True 則不發通知）**

新增 `test/test_workflow_execution_suppress_notify.py`：

```python
# 整合測試：複用 conftest.py 既有 fixture
from unittest.mock import patch

def test_complete_job_suppress_notify_true_skips_notification(client, headers):
    """設 suppress_notify=True 後，notify_user_todo_job 不被呼叫。"""
    with patch("app.flow_engine.service.workflow_execution_service.WorkflowExecutionService.notify_user_todo_job") as mock_notify:
        # 呼叫 complete_job 透過 batch_complete service（最容易觸發）
        # ... 視既有 conftest 提供哪些 fixture，可能需建立 minimal scenario
        # 若難以整合測試，改為 pure unit test patch 整個 method
        pass

# 退而求其次的 unit test
def test_complete_job_signature_accepts_suppress_notify():
    from app.flow_engine.service.workflow_execution_service import WorkflowExecutionService
    import inspect
    sig = inspect.signature(WorkflowExecutionService.complete_job)
    assert "suppress_notify" in sig.parameters
    assert sig.parameters["suppress_notify"].default is False
```

- [ ] **Step 2: Run (RED)**

```bash
pytest test/test_workflow_execution_suppress_notify.py -v
```

Expected: FAIL（signature 還沒改）。

- [ ] **Step 3: 改 `complete_job` signature + 內部條件**

`workflow_execution_service.py:553` 附近找到 `def complete_job(...)`，在 signature 末加 `suppress_notify: bool = False`。

line 572 附近原本：
```python
self.notify_user_todo_job(job_execution.id)
```
改為：
```python
if not suppress_notify:
    self.notify_user_todo_job(job_execution.id)
```

`revert_job` (line 576-) 同樣處理：signature 加 `suppress_notify: bool = False`，line 635 附近 `notify_user_todo_job` 呼叫包 `if not suppress_notify`。

- [ ] **Step 4: Run (GREEN)**

```bash
pytest test/test_workflow_execution_suppress_notify.py -v
```

Expected: PASS。

- [ ] **Step 5: 跑既有 workflow 回歸**

```bash
pytest test/ -k "workflow_execution" -v
```

Expected: 既有測試全 PASS（default 是 False，行為不變）。

- [ ] **Step 6: Commit**

```bash
git add app/flow_engine/service/workflow_execution_service.py test/test_workflow_execution_suppress_notify.py
git commit -m "feat(workflow-execution): add suppress_notify param to complete_job/revert_job"
```

### Task 3.2: workflow_execution_service.py — 三處 fan-out 改 cfg 預解析（拆三個 sub-task 各自 commit）

**Files:**
- Modify: `app/flow_engine/service/workflow_execution_service.py`（三 method：`notify_users_batch_assigned` / `notify_user_todo_job` / `notify_control_reviewers_on_task_complete`）

⚠️ 行號為實作當下 grep 出來，而非寫死：
```bash
grep -nE "def notify_users_batch_assigned|def notify_user_todo_job|def notify_control_reviewers_on_task_complete" \
  app/flow_engine/service/workflow_execution_service.py
```

#### Task 3.2a: `notify_users_batch_assigned`

- [ ] **Step 1: 改 `notify_users_batch_assigned`**

刪除：
```python
notify_modules_value = os.getenv("SEND_NOTIFY_MODULES", None)
if not notify_modules_value:
    return True
notify_modules = notify_modules_value.split(",")
```

method 開頭改加：
```python
discord_cfg = self.notification_service.get_channel_config("DISCORD")
telegram_cfg = self.notification_service.get_channel_config("TELEGRAM")
```

迴圈內三段 `if "MAIL/TELEGRAM/DISCORD" in notify_modules` 改：
```python
threading.Thread(
    target=self.notification_service.send_mail_notification,
    kwargs={"to": user.email, "subject": subject, "content": message, "is_html": True},
).start()  # MAIL 永遠送

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},
    ).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()
```

- [ ] **Step 2: Smoke check（只跑 import + collect 確認沒打錯字）**

```bash
pytest test/ tests/ --co -q 2>&1 | tail -10
```

- [ ] **Step 3: Commit**

```bash
git add app/flow_engine/service/workflow_execution_service.py
git commit -m "refactor(workflow-execution): cfg pre-resolve in notify_users_batch_assigned"
```

⚠️ Full regression 留到 Task 3.2c 三段都改完一起跑（避免重複跑同一套 9 分鐘）。

#### Task 3.2b: `notify_user_todo_job`

- [ ] **Step 1: 改 `notify_user_todo_job`**

刪除 `notify_modules_value` block。預解析放在 `if job.status != JobStatus.PROCESSING: return True` 與 project status check 之後、assignees 迴圈之前：

```python
discord_cfg = self.notification_service.get_channel_config("DISCORD")
telegram_cfg = self.notification_service.get_channel_config("TELEGRAM")
```

迴圈內 fan-out 改成同 Task 3.2a 模式。

- [ ] **Step 2: Smoke check**

```bash
pytest test/ tests/ --co -q 2>&1 | tail -10
```

- [ ] **Step 3: Commit**

```bash
git add app/flow_engine/service/workflow_execution_service.py
git commit -m "refactor(workflow-execution): cfg pre-resolve in notify_user_todo_job"
```

#### Task 3.2c: `notify_control_reviewers_on_task_complete`

- [ ] **Step 1: 改 `notify_control_reviewers_on_task_complete`**

同上模式，預解析放在 reviewer filter 完成、確定有對象後。

- [ ] **Step 2: Run full regression（Task 3.2 三段一起驗）**

```bash
pytest test/ tests/ -k "workflow or flow_engine" -v
```

Expected: 全 PASS。

- [ ] **Step 3: grep 確認 workflow_execution_service.py 無殘留 SEND_NOTIFY_MODULES**

```bash
grep -nE "SEND_NOTIFY_MODULES" app/flow_engine/service/workflow_execution_service.py
```

Expected: 無輸出。

- [ ] **Step 4: Commit**

```bash
git add app/flow_engine/service/workflow_execution_service.py
git commit -m "refactor(workflow-execution): cfg pre-resolve in notify_control_reviewers_on_task_complete"
```

### Task 3.3: project_service.py — fan-out 改 cfg 預解析

**Files:**
- Modify: `app/grc/service/project_service.py:644`

- [ ] **Step 1: 改 fan-out**

仿 Task 3.2 模式，移除 `SEND_NOTIFY_MODULES` block，預解析放 method 開頭。

- [ ] **Step 2: Run regression**

```bash
pytest test/ -k "grc.project or project_service" -v
```

- [ ] **Step 3: Commit**

```bash
git add app/grc/service/project_service.py
git commit -m "refactor(grc-project): main-thread cfg pre-resolve for notify fan-out"
```

### Task 3.4: task_survey_service.py — fan-out 改 cfg 預解析

**Files:**
- Modify: `app/task_survey/service/task_survey_service.py:444`

- [ ] **Step 1: 改 fan-out（保留 assignees 為空 early return）**

預解析放在 early return 後、迴圈前。

- [ ] **Step 2: Run regression**

```bash
pytest test/ -k "task_survey" -v
```

- [ ] **Step 3: Commit**

```bash
git add app/task_survey/service/task_survey_service.py
git commit -m "refactor(task-survey): main-thread cfg pre-resolve for notify fan-out"
```

### Task 3.5: job_batch_complete_service.py — 拔 os.environ 騷操作 + 改 suppress_notify + fan-out

**Files:**
- Modify: `app/grc/service/job_batch_complete_service.py:38-90, 108-, 116`

- [ ] **Step 1: 拔掉 line 38-40 + 87-90**

刪除：
```python
original_notify = os.environ.get("SEND_NOTIFY_MODULES")
os.environ["SEND_NOTIFY_MODULES"] = ""
```
與對應 finally block。

`try` 區塊保留（其他 logic）但內部 `complete_job` 呼叫加 `suppress_notify=True`：
```python
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，整個刪掉。

- [ ] **Step 2: 改 `_send_batch_summary_notification` (line 108-)**

line 116 `notify_modules_value = os.getenv("SEND_NOTIFY_MODULES", None)` 開始的 block 拔掉，預解析改放 method 開頭，fan-out 套 cfg kwargs。

- [ ] **Step 3: 移除 line 94 的 logger 引用 SEND_NOTIFY_MODULES**

```python
logger.info(f"[batch_complete] SEND_NOTIFY_MODULES={os.environ.get('SEND_NOTIFY_MODULES')}")
```
這行刪掉（資訊已過時）。

- [ ] **Step 4: Run regression**

```bash
pytest test/ -k "batch_complete or job_batch" -v
```

- [ ] **Step 5: Final grep — 全主專案無 SEND_NOTIFY_MODULES**

```bash
grep -rnE "SEND_NOTIFY_MODULES" app/ api/ common/ config/ test/ scripts/ --include="*.py"
```

Expected: 完全沒有結果（docs/ 目錄忽略）。

- [ ] **Step 6: Commit**

```bash
git add app/grc/service/job_batch_complete_service.py
git commit -m "refactor(batch-complete): replace os.environ hack with suppress_notify param"
```

### Task 3.6: 移除 .env 通知變數

**Files:**
- Modify: `.env`
- Modify: `.env.example`（如果有）

- [ ] **Step 1: 從 .env 拔除四個 keys**

```diff
-SEND_NOTIFY_MODULES=MAIL,DISCORD,TELEGRAM
-TELEGRAM_BOT_TOKEN=...
-TELEGRAM_CHAT_ID=...
-DISCORD_WEBHOOK_URL=...
+# 通知設定改至 UI 設定頁：/system/notify-config（per-tenant）
+# SMTP 設定改至 UI 設定頁：/system/smtp-config-manage（per-tenant）
```

- [ ] **Step 2: 確認 BE 仍可啟動**

```bash
set -a && source .env && set +a && python main_socketio.py
```

Expected: 啟動成功。

- [ ] **Step 3: Commit `.env.example`（不 commit `.env`）**

```bash
git add .env.example  # 若沒有 .env.example，先建一份
git commit -m "chore(env): drop SEND_NOTIFY_MODULES + telegram/discord vars (moved to per-tenant config UI)"
```

⚠️ `.env` 一般不入 git，本機改完即可。

---

## Phase 4 — system_config_route HIDDEN_SECRET 補洞 (~10 分鐘)

### Task 4.1: 加 NOTIFY_CONFIG 到 HIDDEN_SECRET + 跨 tenant PUT 隔離驗證

**Files:**
- Modify: `api/system_config/routes/system_config_route.py:18`
- Create: `tests/test_system_config_hidden_secret.py`

- [ ] **Step 0: grep 確認沒有其他繞過 HIDDEN_SECRET 的讀取路徑（spec §3.4 要求）**

```bash
grep -rnE "value\[.secret.\]|value\.get\(.secret|value\.pop\(.secret" \
  api/ app/ --include="*.py"
```

Expected: 只有 `api/system_config/routes/system_config_route.py` 內部 hide 邏輯。若發現其他端點直接讀 `value.secret`，列為 follow-up。

- [ ] **Step 1: 寫整合測試（HIDDEN_SECRET + 跨 tenant 隔離）**

新增 `tests/test_system_config_hidden_secret.py`：

```python
import pytest


class TestNotifyConfigSecretHidden:
    def test_get_strips_secret(self, client, admin_headers):
        # 先 PUT 一筆 Discord 設定
        client.put(
            "/api/1.0/system/config/NOTIFY_CONFIG/DISCORD",
            json={"value": {"enabled": True, "secret": "test-webhook-url"}, "changePwd": True},
            headers=admin_headers,
        )
        res = client.get("/api/1.0/system/config/NOTIFY_CONFIG/DISCORD", headers=admin_headers)
        assert res.status_code == 200
        body = res.get_json()
        assert body["status"] is True
        assert "secret" not in body["data"]["value"]

    def test_list_strips_secret(self, client, admin_headers):
        # spec §3.4 / M-5：list endpoint 也要剝
        res = client.get("/api/1.0/system/configs/NOTIFY_CONFIG", headers=admin_headers)
        assert res.status_code == 200
        body = res.get_json()
        for entry in body["data"]:
            assert "secret" not in entry.get("value", {})


@pytest.mark.skip(
    reason="tenant2_headers 只是切 X-Tenant-ID 不重簽 JWT，不能驗 RLS 跨 tenant 隔離。"
           "follow-up：擴 conftest 加真正獨立 token 的 tenant2 fixture，或改 service-level "
           "unit test mock RLS session 變數驗證。"
)
class TestCrossTenantIsolation:
    """spec §5.3.3 / §8.1.2：tenant A 寫的 NOTIFY_CONFIG，tenant B 看不到。"""
    def test_tenant_isolation_via_rls(self, client, admin_headers, tenant2_headers):
        pass  # 留空，等 fixture 修好再啟用
```

⚠️ **跨 tenant 整合測試暫 skip**：
- `tenant2_headers(admin_token)` 用同一 admin token 只切 `X-Tenant-ID` header
- 但 RLS 由 JWT `allowed_tenant_paths` 控制（記憶 `tests/DEPENDENCIES.md`），改 header 無效
- 真要驗，需 conftest 補一個「以另一 tenant 帳號登入拿獨立 token」的 fixture，超出本 PR 範圍
- 列為 follow-up；spec §5.3.3 的 RLS 隔離仍由 PostgreSQL policy 保證（既有 system_configs 已套 `TenantScopedMixinModel`），不靠這條測試

- [ ] **Step 2: Run (RED)**

```bash
pytest tests/test_system_config_hidden_secret.py -v
```

Expected: `test_get_strips_secret` / `test_list_strips_secret` FAIL（secret 被回傳）。`test_tenant_isolation_via_rls` 視 RLS 設定可能已 PASS。

- [ ] **Step 3: 改 system_config_route.py:18**

```python
HIDDEN_SECRET = ['SMTP', 'THIRD_PARTY_LOGIN', 'NOTIFY_CONFIG']
```

- [ ] **Step 4: Run (GREEN)**

```bash
pytest tests/test_system_config_hidden_secret.py -v
```

- [ ] **Step 5: Commit**

```bash
git add api/system_config/routes/system_config_route.py tests/test_system_config_hidden_secret.py
git commit -m "feat(system-config): hide NOTIFY_CONFIG secret + cross-tenant RLS verification"
```

---

## Phase 5 — Notify Config 新模組 (~3 小時)

### Task 5.1: NotifyConfigTestService

**Files:**
- Create: `app/notify_config/__init__.py`
- Create: `app/notify_config/service/__init__.py`
- Create: `app/notify_config/service/notify_config_test_service.py`

- [ ] **Step 1: 寫單元測試**

`test/test_notify_config_test_service.py`：

```python
from unittest.mock import MagicMock, patch
import pytest
from jedi_common.handler.exception import BadRequestError, ForbiddenError, NotFound
from common.code.error_code import ErrorCode


@pytest.fixture
def mock_admin_user():
    user = MagicMock()
    user.is_admin = True
    return user


@pytest.fixture
def mock_non_admin_user():
    user = MagicMock()
    user.is_admin = False
    return user


@pytest.fixture
def system_config_service():
    return MagicMock()


@pytest.fixture
def service(system_config_service):
    from app.notify_config.service.notify_config_test_service import NotifyConfigTestService
    return NotifyConfigTestService(system_config_service)


class TestTestChannel:
    def test_non_admin_raises_forbidden(self, service, mock_non_admin_user):
        with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_non_admin_user):
            with pytest.raises(ForbiddenError) as exc:
                service.test_channel("DISCORD", {"secret": "x", "enabled": True})
            assert exc.value.error_code == "NOTIFY_403001"

    def test_unsupported_channel_raises(self, service, mock_admin_user):
        with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
            with pytest.raises(BadRequestError):
                service.test_channel("UNKNOWN", {})

    def test_mail_channel_unsupported(self, service, mock_admin_user):
        with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
            with pytest.raises(BadRequestError):
                service.test_channel("MAIL", {})

    def test_change_pwd_false_no_existing_secret_raises(
        self, service, system_config_service, mock_admin_user
    ):
        system_config_service.get_system_config_by_key.side_effect = NotFound(ErrorCode.NOTIFY_404001)
        with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
            with pytest.raises(BadRequestError):
                service.test_channel("DISCORD", {"enabled": True}, change_pwd=False)

    def test_discord_success_returns_metadata(
        self, service, mock_admin_user
    ):
        with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
            with patch.object(service, "_test_discord", return_value=None):
                result = service.test_channel(
                    "DISCORD",
                    {"enabled": True, "secret": "https://discord.com/api/webhooks/x"},
                )
        assert result["channel"] == "DISCORD"
        assert "sent_at" in result

    def test_discord_failure_raises_test_failed(
        self, service, mock_admin_user
    ):
        # ⚠️ BadRequestError 簽名 (error_code) 只接 1 參數
        # ⚠️ ClientError 用 .error_code 屬性（非 .code），參考 jedi_common/handler/exception.py
        with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
            with patch.object(service, "_test_discord", side_effect=RuntimeError("401")):
                with pytest.raises(BadRequestError) as exc:
                    service.test_channel(
                        "DISCORD",
                        {"enabled": True, "secret": "x"},
                    )
                assert exc.value.error_code == "NOTIFY_400001"
```

- [ ] **Step 2: Run (RED)**

```bash
pytest test/test_notify_config_test_service.py -v
```

Expected: ALL FAIL（service 還沒建）。

- [ ] **Step 3: 建立 service**

`app/notify_config/__init__.py`、`app/notify_config/service/__init__.py` 為空檔。

`app/notify_config/service/notify_config_test_service.py`：

```python
import logging
from datetime import datetime

from jedi_common.handler.exception import (
    BadRequestError, ForbiddenError, NotFound,
)
from jedi_common.session.auth.auth_context import get_user_context
from jedi_common.session.database.db import transaction
from jedi_notification.app.service.notification_service import NotificationService as Notifier
from jedi_notification.app.dto.notification_request_dto import (
    DiscordRequestDTO, TelegramRequestDTO,
)
from jedi_notification.app.dto.discord_config_dto import DiscordConfigDTO
from jedi_notification.app.dto.telegram_config_dto import TelegramConfigDTO

from common.code.error_code import ErrorCode

logger = logging.getLogger(__name__)


class NotifyConfigTestService:
    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:
        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:
        user = get_user_context()
        if not user or not user.is_admin:
            raise ForbiddenError(ErrorCode.NOTIFY_CHANNEL_TEST_FORBIDDEN)

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

        resolved = self._resolve_value(channel, value, change_pwd)

        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)

    def _test_discord(self, value: dict):
        config = DiscordConfigDTO(webhook_url=value["secret"])
        Notifier("DISCORD", config.dict()).send_notification(
            DiscordRequestDTO(content="✅ Test message from Guidant.AI")
        )

    def _test_telegram(self, value: dict):
        config = TelegramConfigDTO(
            bot_token=value["secret"],
            chat_id=value["chat_id"],
        )
        Notifier("TELEGRAM", config.dict()).send_notification(
            TelegramRequestDTO(message="✅ Test message from Guidant.AI")
        )
```

- [ ] **Step 4: Run (GREEN)**

```bash
pytest test/test_notify_config_test_service.py -v
```

- [ ] **Step 5: Commit**

```bash
git add app/notify_config/ test/test_notify_config_test_service.py
git commit -m "feat(notify-config): add NotifyConfigTestService (GREEN)"
```

### Task 5.2: NotifyConfigContainer

**Files:**
- Create: `di_containers/notify_config/__init__.py`
- Create: `di_containers/notify_config/containers.py`
- Modify: `di_containers/containers.py`

- [ ] **Step 1: 建 container**

`di_containers/notify_config/__init__.py` 空。

`di_containers/notify_config/containers.py`：

```python
from dependency_injector import containers, providers

from app.notify_config.service.notify_config_test_service import NotifyConfigTestService


class NotifyConfigContainer(containers.DeclarativeContainer):
    system_config_service = providers.Dependency()

    notify_config_test_service = providers.Singleton(
        NotifyConfigTestService,
        system_config_service=system_config_service,
    )
```

- [ ] **Step 2: 改 `di_containers/containers.py` wire**

Read 既有檔案找到 `system_config_container` 定義位置，下方新增：

```python
from di_containers.notify_config.containers import NotifyConfigContainer

# 在 Containers class 內：
notify_config_container = providers.Container(
    NotifyConfigContainer,
    system_config_service=system_config_container.system_config_service,
)
```

- [ ] **Step 3: 試跑 BE 啟動**

```bash
set -a && source .env && set +a && python main_socketio.py 2>&1 | head -30
```

Expected: 無 import / DI error。

- [ ] **Step 4: Commit**

```bash
git add di_containers/notify_config/ di_containers/containers.py
git commit -m "feat(di): wire NotifyConfigContainer"
```

### Task 5.3: API Routes + Serializers + create_module

**Files:**
- Create: `api/notify_config/__init__.py`
- Create: `api/notify_config/routes/__init__.py`
- Create: `api/notify_config/routes/notify_config_route.py`
- Create: `api/notify_config/serializers/__init__.py`
- Create: `api/notify_config/serializers/notify_config.py`
- Modify: `config/app_modules.py`

- [ ] **Step 1: 寫整合測試（放 `tests/`，用 `admin_headers` fixture）**

`tests/test_notify_config_route.py`：

```python
from unittest.mock import patch


class TestNotifyConfigTestEndpoint:
    """整合測試只驗 happy path + 4xx wire-level；403 (非 admin) 由 service unit test 涵蓋。"""

    def test_no_jwt_returns_401(self, client, no_auth_headers):
        res = client.post(
            "/api/1.0/notify-config/DISCORD/test",
            json={"value": {}},
            headers=no_auth_headers,
        )
        assert res.status_code == 401

    def test_unsupported_channel_returns_400(self, client, admin_headers):
        res = client.post(
            "/api/1.0/notify-config/MAIL/test",
            json={"value": {}, "changePwd": True},
            headers=admin_headers,
        )
        assert res.status_code == 400
        body = res.get_json()
        # ⚠️ 失敗 envelope 是 {"error_code", "msg"}（jedi-handler 序列化），無 status/data 包
        assert body["error_code"] == "NOTIFY_400003"

    def test_discord_success_returns_200(self, client, admin_headers):
        with patch(
            "app.notify_config.service.notify_config_test_service.NotifyConfigTestService._test_discord",
            return_value=None,
        ):
            res = client.post(
                "/api/1.0/notify-config/DISCORD/test",
                json={
                    "value": {"enabled": True, "secret": "https://discord.com/api/webhooks/x"},
                    "changePwd": True,
                },
                headers=admin_headers,
            )
        assert res.status_code == 200
        body = res.get_json()
        # 成功 envelope 是 {"status": True, "data": {...}}
        assert body["status"] is True
        assert body["data"]["channel"] == "DISCORD"
        assert "sent_at" in body["data"]

    def test_discord_failure_returns_400(self, client, admin_headers):
        with patch(
            "app.notify_config.service.notify_config_test_service.NotifyConfigTestService._test_discord",
            side_effect=RuntimeError("401 Unauthorized"),
        ):
            res = client.post(
                "/api/1.0/notify-config/DISCORD/test",
                json={
                    "value": {"enabled": True, "secret": "x"},
                    "changePwd": True,
                },
                headers=admin_headers,
            )
        assert res.status_code == 400
        body = res.get_json()
        assert body["error_code"] == "NOTIFY_400001"
```

⚠️ `admin_headers` / `no_auth_headers` 由 `tests/conftest.py` 提供，無 non-admin headers fixture，因此「非 admin → 403」case 留給 service-level unit test (Task 5.1) 涵蓋。

- [ ] **Step 2: Run (RED)**

```bash
pytest tests/test_notify_config_route.py -v
```

Expected: ALL FAIL（route 還沒建）。

- [ ] **Step 3: 建 serializers**

`api/notify_config/serializers/notify_config.py`：

```python
from marshmallow import Schema, fields


class NotifyConfigTestRequest(Schema):
    value = fields.Dict(required=True)
    changePwd = fields.Boolean(load_default=True)


class NotifyConfigTestResponse(Schema):
    channel = fields.String()
    sent_at = fields.String()
```

- [ ] **Step 4: 建 route**

`api/notify_config/routes/notify_config_route.py`：

```python
from dependency_injector.wiring import inject, Provide
from flask import request
from flask_apispec import MethodResource, doc, marshal_with, use_kwargs
from flask_jwt_extended import jwt_required

from app.notify_config.service.notify_config_test_service import NotifyConfigTestService
from api.notify_config.serializers.notify_config import (
    NotifyConfigTestRequest, NotifyConfigTestResponse,
)
from common.enum.schema_code import AUTH_PARAMS
from common.util.response_util import return_response
from di_containers.containers import Containers


class NotifyConfigTestRoute(MethodResource):
    @doc(description='測試通知頻道', tags=['Notify Config'], params=AUTH_PARAMS)
    @use_kwargs(NotifyConfigTestRequest, location='json', apply=False)
    @marshal_with(NotifyConfigTestResponse, apply=False)
    @jwt_required()
    @inject
    def post(
        self,
        channel,
        notify_config_test_service: NotifyConfigTestService = Provide[
            Containers.notify_config_container.notify_config_test_service
        ],
    ):
        payload = request.get_json(silent=True) or {}
        result = notify_config_test_service.test_channel(
            channel=channel,
            value=payload.get("value", {}),
            change_pwd=payload.get("changePwd", True),
        )
        return return_response(True, result)
```

- [ ] **Step 5: 建 `api/notify_config/__init__.py`（精準對齊 `api/system_config/__init__.py` pattern）**

⚠️ 既有 `create_module()` **無參數、return blueprint**（不接 `app`、不主動 `register_blueprint`、不主動 `docs.register`，由 `main.py` 外層處理）。直接複製貼上：

```python
"""
Notify Config Module - Flask Blueprint Configuration
路由配置
"""

from flask import Blueprint
from flask_restful import Api


def create_module():
    from api.notify_config.routes.notify_config_route import NotifyConfigTestRoute
    bp = Blueprint('notify-config', __name__, url_prefix='/api/1.0')
    api = Api(bp)
    api.add_resource(NotifyConfigTestRoute, '/notify-config/<string:channel>/test')
    return bp


def create_doc():
    pass
```

- [ ] **Step 6: 改 `config/app_modules.py`**

`REGISTERED_APPS` 加入 `'notify_config'`（順序視既有慣例插在 `'system_config'` 後）。

- [ ] **Step 7: Run (GREEN)**

```bash
pytest tests/test_notify_config_route.py -v
```

- [ ] **Step 8: Manual smoke（依 CLAUDE memory `feedback_backend_restart_orphan_pids.md` 用 lsof + kill -9）**

```bash
# 啟動前先清乾淨
lsof -ti :8000 | xargs kill -9 2>/dev/null

set -a && source .env && set +a
python main_socketio.py &
BE_PID=$!
sleep 3

curl -X POST http://localhost:8000/api/1.0/notify-config/DISCORD/test \
  -H "Authorization: Bearer <admin_token>" \
  -H "Content-Type: application/json" \
  -d '{"value": {"enabled": true, "secret": "https://discord.com/api/webhooks/INVALID"}, "changePwd": true}'

# 收尾
kill -9 $BE_PID 2>/dev/null
lsof -ti :8000 | xargs kill -9 2>/dev/null
```

Expected: 400 + envelope `{"error_code": "NOTIFY_400001", "msg": "通知測試失敗"}`（無 status/data 包）。

- [ ] **Step 9: Commit**

```bash
git add api/notify_config/ config/app_modules.py tests/test_notify_config_route.py
git commit -m "feat(notify-config): add POST /notify-config/<channel>/test endpoint"
```

---

## Phase 6 — DB Migration (~30 分鐘)

### Task 6.1: 寫 migration SQL

**Files:**
- Create: `scripts/sql/2026-04-27-add-notify-config-permissions.sql`

- [ ] **Step 1: 寫 migration（直接 inline，不依賴 spec 同步）**

```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>
-- Idempotent：可重複執行

BEGIN;

-- ⚠️ 寫 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，改用 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';
```

- [ ] **Step 2: 跑 migration**

```bash
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg \
  -f scripts/sql/2026-04-27-add-notify-config-permissions.sql
```

Expected: `BEGIN ... INSERT 1 ... INSERT 2 ... INSERT 2 ... INSERT 2 ... COMMIT`

- [ ] **Step 3: 重跑驗證 idempotent**

```bash
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg \
  -f scripts/sql/2026-04-27-add-notify-config-permissions.sql
```

Expected: `BEGIN ... INSERT 0 ... INSERT 0 ... INSERT 0 ... INSERT 0 ... COMMIT`（全部 skip）。

- [ ] **Step 4: 驗證資料寫入**

```sql
SELECT * FROM public.capabilities WHERE resource_type = 'notify_config';
SELECT * FROM public.ui_routes WHERE name = 'notify-config';
SELECT rc.*, c.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 r.name = 'notify-config';
SELECT rc.*, c.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';
```

Expected: 2 capability + 1 ui_route + 2 route_capability + 2 role_capability。

- [ ] **Step 5: Commit**

```bash
git add scripts/sql/2026-04-27-add-notify-config-permissions.sql
git commit -m "migration(notify-config): add ui_routes/capabilities/role_capabilities (2026-04-27)"
```

---

## Phase 7 — Frontend (~3 小時)

### Task 7.1: API 常數 + i18n

**Files:**
- Modify: `src/config/api/api.js`
- Create: `src/config/locales/i18n/zh-tw/notify-config.json`
- Create: `src/config/locales/i18n/en/notify-config.json`
- Create: `src/config/locales/i18n/zh-cn/notify-config.json`
- Modify: `src/config/locales/i18n/{en,zh-tw,zh-cn}/common.json`（若 `save_failed` 不存在）

⚠️ 前端 i18n 結構：每個 view 一個 namespace JSON（非 JS object）；命名 kebab-case；目錄為 `zh-tw`（非 `zh_Hant_TW`）。

- [ ] **Step 1: 加 API 常數**

`src/config/api/api.js` 末尾加：
```js
NOTIFY_CONFIG_TEST: getUrl('/notify-config'),
```

- [ ] **Step 2: 確認 vue-i18n key 用法 + `save_failed` 存在性**

```bash
# 對照既有 view 怎麼用 t()
grep -nE "t\('common\.save" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/issue-integrate-config/IssueIntegrateConfigForm.vue

# 看 common.json 已有什麼 keys
grep -E "save_" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/config/locales/i18n/zh-tw/common.json
```

→ 若 `save_failed` 不存在，三個 locale 的 `common.json` 各加一筆。

- [ ] **Step 3: 建 `notify-config.json`（zh-tw）**

`src/config/locales/i18n/zh-tw/notify-config.json`：
```json
{
  "title": "通知設定",
  "mail_help": "Email 通知由 SMTP 設定頁控制",
  "mail_help_link": "前往 SMTP 設定",
  "discord": "Discord",
  "telegram": "Telegram",
  "enabled": "啟用",
  "webhook_url": "Webhook URL",
  "bot_token": "Bot Token",
  "chat_id": "Chat ID",
  "save_button": "儲存",
  "test_button": "測試送一則",
  "test_success": "測試訊息已送出",
  "test_failed": "測試失敗"
}
```

- [ ] **Step 4: 建 `notify-config.json`（en）**

```json
{
  "title": "Notification Settings",
  "mail_help": "Email notifications are controlled via SMTP settings",
  "mail_help_link": "Go to SMTP settings",
  "discord": "Discord",
  "telegram": "Telegram",
  "enabled": "Enabled",
  "webhook_url": "Webhook URL",
  "bot_token": "Bot Token",
  "chat_id": "Chat ID",
  "save_button": "Save",
  "test_button": "Send Test Message",
  "test_success": "Test message sent",
  "test_failed": "Test failed"
}
```

- [ ] **Step 5: 建 `notify-config.json`（zh-cn）**

複製 zh-tw 為簡體（保留 key、translations 改簡體字）。若團隊未維護 zh-cn 同步，照既有最新一個 view 的 zh-cn 完整度處理。

- [ ] **Step 6: 確認 i18n loader 自動載入新 namespace**

`src/config/locales/index.js` 是否自動掃 JSON 還是要手動 register？grep 確認：
```bash
grep -nE "import|require|notify-config" src/config/locales/index.js src/config/locales/i18n/sync-translations.js | head -10
```
→ 若是 auto-scan，不必動；若手動，補一行 import。

- [ ] **Step 7: Commit**

```bash
git add src/config/api/api.js src/config/locales/i18n/
git commit -m "feat(fe-notify-config): add API const + i18n namespace"
```

### Task 7.2: NotifyConfigForm.vue

**Files:**
- Create: `src/views/notify-config/NotifyConfigForm.vue`

- [ ] **Step 1: Read 範本**

```bash
cat src/views/issue-integrate-config/IssueIntegrateConfigForm.vue
```

抄結構。

- [ ] **Step 2: 建 NotifyConfigForm.vue**

範本（依實際範本可能要微調）：

```vue
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
// ⚠️ 對齊既有 IssueIntegrateConfigForm.vue:5-6 import 慣例：
//   - BaseService 是 named export，要實例化
//   - API 是 default export
import { BaseService } from '@/service/BaseService';
import API from '@/config/api/api';
import { useToastUtil } from '@/utils/toastUtil';

const { showError, showSuccess } = useToastUtil();
const { t } = useI18n();
const baseService = new BaseService();

const tabIndex = ref(0);
const submitted = ref(false);

const discord = ref({ enabled: false, webhook_url: '', changePwd: false });
const telegram = ref({ enabled: false, bot_token: '', chat_id: '', changePwd: false });

const SECRET_PLACEHOLDER = '********';

onMounted(async () => {
    await Promise.all([loadDiscord(), loadTelegram()]);
});

async function loadDiscord() {
    try {
        const res = await baseService.get(API.SYSTEM_CONFIG + '/NOTIFY_CONFIG/DISCORD');
        if (res?.value) {
            discord.value.enabled = res.value.enabled ?? false;
            discord.value.webhook_url = res.value.secret !== undefined ? '' : SECRET_PLACEHOLDER;
            // 後端 GET 已 strip secret，前端用 placeholder 顯示「已存在」
        }
    } catch (e) {
        // 沒設過 → 表單空白即可
    }
}

async function loadTelegram() {
    try {
        const res = await baseService.get(API.SYSTEM_CONFIG + '/NOTIFY_CONFIG/TELEGRAM');
        if (res?.value) {
            telegram.value.enabled = res.value.enabled ?? false;
            telegram.value.bot_token = SECRET_PLACEHOLDER;
            telegram.value.chat_id = res.value.chat_id ?? '';
        }
    } catch (e) {
        // 沒設過
    }
}

function buildPayload(channel) {
    if (channel === 'DISCORD') {
        return {
            value: {
                enabled: discord.value.enabled,
                secret: discord.value.webhook_url === SECRET_PLACEHOLDER ? '' : discord.value.webhook_url,
            },
            changePwd: discord.value.webhook_url !== SECRET_PLACEHOLDER && discord.value.webhook_url !== '',
        };
    }
    return {
        value: {
            enabled: telegram.value.enabled,
            secret: telegram.value.bot_token === SECRET_PLACEHOLDER ? '' : telegram.value.bot_token,
            chat_id: telegram.value.chat_id,
        },
        changePwd: telegram.value.bot_token !== SECRET_PLACEHOLDER && telegram.value.bot_token !== '',
    };
}

// ⚠️ BaseService 已自動把 envelope `{status:true, data:...}` 解開回傳 `data`，
//    失敗時 reject `data` 本體（jedi-handler 包的 `{message, error_code}`）—— 不是 axios error
// ⚠️ vue-i18n 用 namespace.key 取（namespace = JSON 檔名 kebab-case）
//    e.g. t('common.save_success'), t('notify-config.test_success')
//    Phase 7 Task 7.1 Step 2 grep 確認既有用法
async function onSave(channel) {
    submitted.value = true;
    try {
        const payload = buildPayload(channel);
        await baseService.put(API.SYSTEM_CONFIG + `/NOTIFY_CONFIG/${channel}`, payload);
        showSuccess(t('common.save_success'));
        if (channel === 'DISCORD') await loadDiscord();
        else await loadTelegram();
    } catch (e) {
        // axios 4xx response → e.response.data.msg（jedi-handler envelope）
        const errMsg = e?.response?.data?.msg || t('common.save_failed');
        showError(errMsg);
    } finally {
        submitted.value = false;
    }
}

async function onTest(channel) {
    submitted.value = true;
    try {
        const payload = buildPayload(channel);
        await baseService.post(API.NOTIFY_CONFIG_TEST + `/${channel}/test`, payload);
        showSuccess(t('notify-config.test_success'));
    } catch (e) {
        // BadRequestError 已拔掉 detail kwarg，前端只顯示固定 msg
        // 失敗 envelope 是 {"error_code", "msg"}（無 status/data 包）
        const errMsg = e?.response?.data?.msg || t('notify-config.test_failed');
        showError(errMsg);
    } finally {
        submitted.value = false;
    }
}
</script>

<template>
    <div class="p-4">
        <h3>{{ t('notify-config.title') }}</h3>

        <Message severity="info" :closable="false">
            {{ t('notify-config.mail_help') }}
            <router-link to="/system/smtp-config-manage" class="ml-2">
                {{ t('notify-config.mail_help_link') }}
            </router-link>
        </Message>

        <TabView v-model:activeIndex="tabIndex" class="mt-3">
            <TabPanel :header="t('notify-config.discord')">
                <div class="field-checkbox">
                    <Checkbox v-model="discord.enabled" inputId="d-enabled" :binary="true" />
                    <label for="d-enabled" class="ml-2">{{ t('notify-config.enabled') }}</label>
                </div>
                <div class="field mt-3">
                    <label>{{ t('notify-config.webhook_url') }}</label>
                    <InputText v-model="discord.webhook_url" type="password" class="w-full" />
                </div>
                <div class="mt-3">
                    <Button :label="t('notify-config.save_button')" :loading="submitted" @click="onSave('DISCORD')" />
                    <Button :label="t('notify-config.test_button')" :loading="submitted" @click="onTest('DISCORD')" class="ml-2" severity="secondary" />
                </div>
            </TabPanel>

            <TabPanel :header="t('notify-config.telegram')">
                <div class="field-checkbox">
                    <Checkbox v-model="telegram.enabled" inputId="t-enabled" :binary="true" />
                    <label for="t-enabled" class="ml-2">{{ t('notify-config.enabled') }}</label>
                </div>
                <div class="field mt-3">
                    <label>{{ t('notify-config.bot_token') }}</label>
                    <InputText v-model="telegram.bot_token" type="password" class="w-full" />
                </div>
                <div class="field mt-3">
                    <label>{{ t('notify-config.chat_id') }}</label>
                    <InputText v-model="telegram.chat_id" class="w-full" />
                </div>
                <div class="mt-3">
                    <Button :label="t('notify-config.save_button')" :loading="submitted" @click="onSave('TELEGRAM')" />
                    <Button :label="t('notify-config.test_button')" :loading="submitted" @click="onTest('TELEGRAM')" class="ml-2" severity="secondary" />
                </div>
            </TabPanel>
        </TabView>
    </div>
</template>
```

⚠️ 範本依實際 `IssueIntegrateConfigForm.vue` 細節調整（component import、樣式 class）。

- [ ] **Step 3: 加 router**

`src/config/router/index.js`，找到 `/system/issue-integrate-config` 那塊，前面加：

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

- [ ] **Step 4: 啟動前端 + manual smoke（依 kill -9 規範）**

```bash
# 啟前端前清乾淨 port 5180
lsof -ti :5180 | xargs kill -9 2>/dev/null

cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm run dev &
FE_PID=$!
sleep 5
# 開瀏覽器：http://localhost:5180/system/notify-config，blsadmin 登入

# 驗證完收尾
kill -9 $FE_PID 2>/dev/null
lsof -ti :5180 | xargs kill -9 2>/dev/null
```

驗證項目：
1. 頁面打開 → 看到 Discord / Telegram 兩 tab + SMTP info 提示
2. 填 Discord webhook URL → 「儲存」 → toast green
3. 重新整理 → secret 顯示為 `********`
4. 「測試」按鈕 → toast green（如果 webhook 有效）/ red（如果無效）

- [ ] **Step 5: Commit**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/views/notify-config/ src/config/router/index.js
git commit -m "feat(fe-notify-config): add NotifyConfigForm view + route"
```

---

## Phase 8 — End-to-End 驗收 (~1 小時)

### Task 8.1: Pytest 全跑

- [ ] **Step 1: 全測試**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
pytest test/ -v 2>&1 | tail -50
```

Expected: PASS 數量 ≥ Phase 0 baseline + 新增的測試。任何 NEW failures 必須處理。

### Task 8.2: 手動驗收

依 spec §8.2 跑完整 checklist：

| 場景 | 結果 |
|---|---|
| dev 登入 blsadmin → 進「通知設定」頁 → 兩 tab | ☐ |
| 填 Discord webhook URL + 「測試」→ Discord 收到 | ☐ |
| webhook URL 故意填錯 → 「測試」 → toast 紅 + detail | ☐ |
| 儲存後重新整理 → secret 顯示 `********` | ☐ |
| 觸發 `notify_users_batch_assigned` → Discord 收到（依 dev 環境是否有觸發路徑）| ☐ |
| `enabled=false` → 觸發事件 → Discord 不發 + log line | ☐ |
| 跨 tenant 測（用不同 tenant 的不同帳號）→ 設定不互通 | ☐ |
| `job_batch_complete` → 期間不送逐筆通知 → 完成後彙總一次 | ☐ |

### Task 8.3: 寫 Changelog

**Files:**
- Create: `docs/changelog/2026-04-27-feat-notify-config.md`

- [ ] **Step 1: 寫 changelog**

```markdown
---
type: feat
breaking: false
modules: [notification, system-config, workflow-execution, batch-complete, task-survey, grc-project]
issue: # (no issue, brainstorming-driven)
commit: <fill after final commit>
---

# 通知設定 per-tenant 化

## 需求說明（為什麼）

`.env` 裡的 `TELEGRAM_BOT_TOKEN` / `DISCORD_WEBHOOK_URL` 為單租戶設計，多客戶共用部署時 A 客戶的審計通知會打到 B 客戶的頻道。multi-tenant SaaS roadmap 也需要把通知設定下放給 tenant admin 自助管理。

## 變更範圍

### 後端
- `app/notification/service/notification_service.py` 重寫
- 新模組 `api/notify_config/` + `app/notify_config/`
- `app/flow_engine/service/workflow_execution_service.py`：3 處 fan-out 改主 thread 預解析 cfg；`complete_job` / `revert_job` 加 `suppress_notify` 參數
- `app/grc/service/project_service.py`、`app/task_survey/service/task_survey_service.py`、`app/grc/service/job_batch_complete_service.py`：移除 `os.getenv("SEND_NOTIFY_MODULES")`
- `api/system_config/routes/system_config_route.py:18` HIDDEN_SECRET 加 NOTIFY_CONFIG
- `common/code/error_code.py` 新增 NOTIFY_* + 修 NOTIFICATION_404001 共用 bug
- `scripts/sql/2026-04-27-add-notify-config-permissions.sql`
- pytest unit + 整合測試
- i18n .po 更新 + pybabel compile

### 前端
- `src/views/notify-config/NotifyConfigForm.vue` 新增（Discord / Telegram 雙 tab）
- router / api.js / i18n 對應更新

### .env
- 移除 `SEND_NOTIFY_MODULES` / `TELEGRAM_*` / `DISCORD_WEBHOOK_URL`

## API 變更

新增：
- `POST /api/1.0/notify-config/<channel>/test` — body `{value, changePwd}`，回傳測試結果

CRUD 重用既有 `/api/1.0/system/config/NOTIFY_CONFIG/<channel>`。

## 測試結果

```
<pytest output 摘要貼這>
```

## 參考資訊

- Spec: `docs/features/FR-020-2604-notify-config/design.md`
- Plan: `docs/features/FR-020-2604-notify-config/implementation-plan.md`
- 後續工作：見 spec §12 Future Considerations
```

- [ ] **Step 2: Frontmatter `commit:` 寫「主 feature commit hash」**

⚠️ 不要寫 changelog 自己的 hash（雞生蛋）。`commit:` 應指向**最能代表本次 feature 主體的 commit**——通常是 Phase 7 Task 7.2 的 NotifyConfigForm.vue commit hash（前端使用者看得到的入口）：

```bash
# 在前端 repo 抓出 Phase 7 Task 7.2 的 commit hash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe && git log --oneline -1 | awk '{print $1}'
# 把它填進 changelog frontmatter 的 commit: 欄位
```

- [ ] **Step 3: Commit changelog**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git add docs/changelog/2026-04-27-feat-notify-config.md
git commit -m "docs(changelog): notify-config per-tenant feature"
```

→ changelog 自己的 commit 不需要回填 hash。`commit:` 欄位指向 feature 主體 commit 即可。

---

## Final Checklist

- [ ] Phase 0 — 前置驗證 (5 task)
- [ ] Phase 1 — Error codes (2 task)
- [ ] Phase 2 — NotificationService (2 task)
- [ ] Phase 3 — Caller 清理 (Task 3.1 / 3.2a / 3.2b / 3.2c / 3.3 / 3.4 / 3.5 / 3.6)
- [ ] Phase 4 — HIDDEN_SECRET + 跨 tenant 測試 (1 task)
- [ ] Phase 5 — Notify Config 模組 (3 task)
- [ ] Phase 6 — Migration (1 task)
- [ ] Phase 7 — Frontend (2 task)
- [ ] Phase 8 — 驗收 + changelog (3 task)

**總計：~26 個 task，預估 15-18 小時**

---

## 注意事項與已知風險

1. **Thread-local user_context 是這次最容易踩的坑** — 任何時候在 `threading.Thread` 內呼叫 `notification_service.send_*` 都必須由 caller 主 thread 預解析 cfg 後傳入。spec §10 規範：未來新 caller 也要遵守。
2. **`complete_job` / `revert_job` signature 變更** — default 是 False 對既有 caller 透明，但若有 monkey-patch 既有 method 的測試 / 程式碼，可能要對應更新。Phase 3 Task 3.1 的回歸測試應該抓得到。
3. **Migration 不能跑兩次寫到 ui_routes 重複** — Phase 6 Task 6.1 Step 3 重跑驗證 idempotent，若失敗回去檢查 SQL `INSERT ... SELECT WHERE NOT EXISTS` 寫法。
4. **dev DB port 是 25432 不是 5432** — 記憶 `reference_dev_db_psql_port.md`，不要漏。
5. **migration 用 cmmgr 跑不要 cm_app** — 記憶 `feedback_sql_migration_use_cmmgr.md`，cm_app 受 RLS 擋會靜默失敗。
6. **既有 SMTP/* thread-local 同根源 bug** — 不在本 PR 範圍（spec §9.3）。本 PR 結束後另開 PR 套用相同 cfg 預解析模式。
7. **Capability check 全家族補洞** — `api/system_config/routes/` 全 method 仍只 `@jwt_required`，不做後端 capability gate（spec §9.1）。Test endpoint 例外（spec §5.3.2 強制 `is_admin`）。
