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

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.jsstatus === 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,不傳到前端

§1

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=Truecomplete_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 不存在則新增

§2

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

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

Task 0.1: 驗證 DB schema

Files: N/A(純 DB query 驗證)

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)

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。

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

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

# 對每個 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 設定

ls -R config/translations/

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

pybabel compile -d config/translations

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

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

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 環境啟動

依記憶 reference_dev_env_setup.md

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/ 可開。

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

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

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 驗)。


§3

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

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

Files:

  • Modify: common/code/error_code.py
grep -n "NOTIFICATION_" common/code/error_code.py

記下行號範圍。

新增 test/test_error_code_uniqueness.py(如果還沒有):

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]}"
pytest test/test_error_code_uniqueness.py -v

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

  • 刪除 NOTIFICATION_DISCORD_CONFIG_NOT_FOUNDNOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND
  • NOTIFICATION_SMTP_CONFIG_NOT_FOUND code 從 "NOTIFICATION_404001" 改為 "NOTIFY_404001"
  • 在 NOTIFICATION 區塊末加:
    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")
pytest test/test_error_code_uniqueness.py -v

Expected: PASS。

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)。

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
grep -n "NOTIFICATION_" config/translations/zh_Hant_TW/LC_MESSAGES/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,無法讀取通知設定"

同上,但 msgstr 改為英文。

pybabel compile -d config/translations

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

# 確認 .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 沒排除,先補規則或這次手動排除。


§4

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

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

Files:

  • Create: test/test_notification_service.py
# 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")
pytest test/test_notification_service.py -v

Expected: 多數 FAIL(NotificationService 還沒改寫)。

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
cat app/notification/service/notification_service.py
# -*- 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)
        )
pytest test/test_notification_service.py -v

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

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

§5

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-

新增 test/test_workflow_execution_suppress_notify.py

# 整合測試:複用 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
pytest test/test_workflow_execution_suppress_notify.py -v

Expected: FAIL(signature 還沒改)。

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

line 572 附近原本:

self.notify_user_todo_job(job_execution.id)

改為:

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

pytest test/test_workflow_execution_suppress_notify.py -v

Expected: PASS。

pytest test/ -k "workflow_execution" -v

Expected: 既有測試全 PASS(default 是 False,行為不變)。

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 出來,而非寫死:

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

刪除:

notify_modules_value = os.getenv("SEND_NOTIFY_MODULES", None)
if not notify_modules_value:
    return True
notify_modules = notify_modules_value.split(",")

method 開頭改加:

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 改:

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()
pytest test/ tests/ --co -q 2>&1 | tail -10
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

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

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

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

pytest test/ tests/ --co -q 2>&1 | tail -10
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

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

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

Expected: 全 PASS。

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

Expected: 無輸出。

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

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

pytest test/ -k "grc.project or project_service" -v
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

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

pytest test/ -k "task_survey" -v
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

刪除:

original_notify = os.environ.get("SEND_NOTIFY_MODULES")
os.environ["SEND_NOTIFY_MODULES"] = ""

與對應 finally block。

try 區塊保留(其他 logic)但內部 complete_job 呼叫加 suppress_notify=True

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,整個刪掉。

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

logger.info(f"[batch_complete] SEND_NOTIFY_MODULES={os.environ.get('SEND_NOTIFY_MODULES')}")

這行刪掉(資訊已過時)。

pytest test/ -k "batch_complete or job_batch" -v
grep -rnE "SEND_NOTIFY_MODULES" app/ api/ common/ config/ test/ scripts/ --include="*.py"

Expected: 完全沒有結果(docs/ 目錄忽略)。

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(如果有)
-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)
set -a && source .env && set +a && python main_socketio.py

Expected: 啟動成功。

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,本機改完即可。


§6

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
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。

新增 tests/test_system_config_hidden_secret.py

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),不靠這條測試
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。

HIDDEN_SECRET = ['SMTP', 'THIRD_PARTY_LOGIN', 'NOTIFY_CONFIG']
pytest tests/test_system_config_hidden_secret.py -v
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"

§7

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

test/test_notify_config_test_service.py

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"
pytest test/test_notify_config_test_service.py -v

Expected: ALL FAIL(service 還沒建)。

app/notify_config/__init__.pyapp/notify_config/service/__init__.py 為空檔。

app/notify_config/service/notify_config_test_service.py

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")
        )
pytest test/test_notify_config_test_service.py -v
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

di_containers/notify_config/__init__.py 空。

di_containers/notify_config/containers.py

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,
    )

Read 既有檔案找到 system_config_container 定義位置,下方新增:

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,
)
set -a && source .env && set +a && python main_socketio.py 2>&1 | head -30

Expected: 無 import / DI error。

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

tests/test_notify_config_route.py

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_headerstests/conftest.py 提供,無 non-admin headers fixture,因此「非 admin → 403」case 留給 service-level unit test (Task 5.1) 涵蓋。

pytest tests/test_notify_config_route.py -v

Expected: ALL FAIL(route 還沒建)。

api/notify_config/serializers/notify_config.py

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()

api/notify_config/routes/notify_config_route.py

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)

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

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

REGISTERED_APPS 加入 'notify_config'(順序視既有慣例插在 'system_config' 後)。

pytest tests/test_notify_config_route.py -v
# 啟動前先清乾淨
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 包)。

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"

§8

Phase 6 — DB Migration (~30 分鐘)

Task 6.1: 寫 migration SQL

Files:

  • Create: scripts/sql/2026-04-27-add-notify-config-permissions.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';
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

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)。

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。

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

§9

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)。

src/config/api/api.js 末尾加:

NOTIFY_CONFIG_TEST: getUrl('/notify-config'),
# 對照既有 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 各加一筆。

src/config/locales/i18n/zh-tw/notify-config.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": "測試失敗"
}
{
  "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"
}

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

src/config/locales/index.js 是否自動掃 JSON 還是要手動 register?grep 確認:

grep -nE "import|require|notify-config" src/config/locales/index.js src/config/locales/i18n/sync-translations.js | head -10

→ 若是 auto-scan,不必動;若手動,補一行 import。

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
cat src/views/issue-integrate-config/IssueIntegrateConfigForm.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)。

src/config/router/index.js,找到 /system/issue-integrate-config 那塊,前面加:

{
    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,
            }
        }
    ]
},
# 啟前端前清乾淨 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(如果無效)
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"

§10

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

Task 8.1: Pytest 全跑

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
---
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

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

# 在前端 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: 欄位
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 即可。


§11

Final Checklist

總計:~26 個 task,預估 15-18 小時


§12

注意事項與已知風險

  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)。