From c79c773d677d08ca04c3e6a5c0df895cef6fe0f3 Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 16 Apr 2026 17:48:36 +0800 Subject: [PATCH] feat(notification): add target_mode enum constraint and merge register-notifications script - Add NotificationTargetMode enum (new_users/exist_users/all_users/user_ids) - Create Alembic migrations: drop duplicate indexes, add target_mode column - Merge register-notifications.sh into dev-migrate.sh sync-notifications subcommand - Shorten notification config path: static/notification/notifications -> static/notifications - Update registration flow to dispatch notifications by target_mode - Add is_first_registration to RegisterBonusResult for first-time user detection - Remove dead code: link_published_notifications_to_user - Update welcome_points.yaml to target new_users only - Add 44 unit tests + 1 integration test, all passing --- .../check.jsonl | 1 + .../debug.jsonl | 1 + .../implement.jsonl | 1 + .../prd.md | 276 ++++++++++++++++++ .../task.json | 44 +++ ...0260416_0002_drop_duplicate_llm_indexes.py | 25 ++ ...60416_0003_add_notification_target_mode.py | 37 +++ .../core/config/notification/static_schema.py | 11 +- .../core/config/notification/static_sync.py | 28 +- .../notifications/README.md | 0 .../notifications/welcome_points.yaml | 2 +- backend/src/core/runtime/cli.py | 6 +- backend/src/models/notification.py | 8 + backend/src/schemas/enums.py | 7 + backend/src/utils/paths.py | 2 +- backend/src/v1/auth/router.py | 7 +- backend/src/v1/notifications/repository.py | 18 +- backend/src/v1/notifications/service.py | 8 +- backend/src/v1/points/service.py | 12 +- .../test_notification_target_mode.py | 96 ++++++ .../unit/test_notification_target_mode.py | 150 ++++++++++ .../test_register_bonus_first_registration.py | 125 ++++++++ .../unit/test_static_notification_sync.py | 154 +++++++++- .../static-notification-sync-protocol.md | 8 +- infra/scripts/dev-migrate.sh | 14 +- infra/scripts/register-notifications.sh | 19 -- 26 files changed, 1011 insertions(+), 49 deletions(-) create mode 100644 .trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/check.jsonl create mode 100644 .trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/debug.jsonl create mode 100644 .trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/prd.md create mode 100644 .trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/task.json create mode 100644 backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py create mode 100644 backend/alembic/versions/20260416_0003_add_notification_target_mode.py rename backend/src/core/config/static/{notification => }/notifications/README.md (100%) rename backend/src/core/config/static/{notification => }/notifications/welcome_points.yaml (93%) create mode 100644 backend/tests/integration/test_notification_target_mode.py create mode 100644 backend/tests/unit/test_notification_target_mode.py create mode 100644 backend/tests/unit/test_register_bonus_first_registration.py delete mode 100755 infra/scripts/register-notifications.sh diff --git a/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/check.jsonl b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/check.jsonl new file mode 100644 index 0000000..0c1d282 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/check.jsonl @@ -0,0 +1 @@ +{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"} diff --git a/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/debug.jsonl b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/debug.jsonl new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/debug.jsonl @@ -0,0 +1 @@ + diff --git a/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/implement.jsonl b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/implement.jsonl new file mode 100644 index 0000000..4752e3d --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"} diff --git a/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/prd.md b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/prd.md new file mode 100644 index 0000000..d88a085 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/prd.md @@ -0,0 +1,276 @@ +# PRD: 修复通知 targets 约束、合并注册脚本、路径缩短及清理重复索引 + +> Task: `04-16-fix-notification-targets-cleanup` +> Branch: `worktree/fix-notification-targets-cleanup` +> Status: planning + +--- + +## 背景与动机 + +当前代码库存在四类独立但可并行修复的问题: + +1. **数据库重复索引** — `llm_factory.name` 和 `llms.model_code` 各有两个功能完全相同的 unique index,浪费存储并拖慢写入。 +2. **脚本碎片化** — `register-notifications.sh` 作为独立脚本存在,但其功能本质上是 `dev-migrate.sh` 的一个子命令(与 migrate / init-data 并列),且未被 bootstrap 覆盖。 +3. **路径冗余** — `backend/src/core/config/static/notification/notifications` 存在两层嵌套的 `notification`,可缩短为 `backend/src/core/config/static/notifications`。 +4. **targets 语义缺失** — 通知 YAML 中的 `targets.mode` 仅支持 `all_users` 和 `user_ids`,缺少对"新用户"和"已存在用户"的精确语义区分,且当前 `link_published_notifications_to_user` 在用户注册时无差别地关联所有 published 通知,无法满足"只推给新注册用户"的需求。 + +--- + +## 任务 1:清理数据库重复索引 + +### 现状分析 + +数据库中确认存在以下重复索引(通过 `pg_indexes` 查询确认): + +| 表 | 冲突索引 | 列 | 类型 | +|---|---|---|---| +| `llm_factory` | `ix_llm_factory_name` vs `llm_factory_name_key` | `name` | UNIQUE BTREE | +| `llms` | `ix_llms_model_code` vs `llms_model_code_key` | `model_code` | UNIQUE BTREE | + +根因:Alembic migration `20260411_0001` 同时定义了 `UniqueConstraint("name")`(自动生成 `*_key`)和 `op.create_index("ix_*", ..., unique=True)`,导致两个相同功能的索引。 + +### 实现方案 + +1. 创建新 Alembic migration,drop 冗余索引: + - `llm_factory`: 保留 `llm_factory_name_key`(UniqueConstraint 自动生成的),drop `ix_llm_factory_name` + - `llms`: 保留 `llms_model_code_key`,drop `ix_llms_model_code` +2. 在 migration 的 `downgrade()` 中恢复被 drop 的索引。 +3. 更新 `20260411_0001` migration 的 `downgrade()` 中对应的 `drop_index` 调用,改为 drop 保留的那个索引名(因为 `ix_*` 将不再存在)。 +4. ORM model 中 `unique=True` 已在 `mapped_column` 上声明,不需要改动。 + +### 涉及文件 + +- `backend/alembic/versions/` — 新增 migration +- `backend/alembic/versions/20260411_0001_initial_llm_schema.py` — 修正 downgrade + +--- + +## 任务 2:将 register-notifications.sh 合并到 dev-migrate.sh + +### 现状分析 + +- `infra/scripts/register-notifications.sh`:加载 env 后调用 `python -m core.runtime.cli sync-notifications "$@"` +- `infra/scripts/dev-migrate.sh`:支持 `migrate`、`init-data`、`bootstrap` 三个子命令 +- `backend/src/core/runtime/cli.py`:`main()` 已支持 `sync-notifications` 命令 +- `bootstrap()` 函数(cli.py:86)仅执行 migrate + init-data,不含 sync-notifications + +### 实现方案 + +1. **修改 `dev-migrate.sh`**:添加 `sync-notifications` 子命令,透传所有参数给 CLI +2. **修改 `dev-migrate.sh` 的 `bootstrap` 子命令**:使其在 migrate + init-data 之后也执行 sync-notifications +3. **修改 `cli.py` 的 `bootstrap()` 函数**:在 `run_init_data()` 之后调用 `run_sync_notifications()` +4. **删除 `infra/scripts/register-notifications.sh`** +5. 更新 usage 文本 + +### 涉及文件 + +- `infra/scripts/dev-migrate.sh` — 添加子命令,修改 bootstrap +- `backend/src/core/runtime/cli.py` — 修改 `bootstrap()` 和 `main()` +- `infra/scripts/register-notifications.sh` — 删除 + +--- + +## 任务 3:缩短 notification 配置路径 + +### 现状分析 + +当前路径:`backend/src/core/config/static/notification/notifications/welcome_points.yaml` + +目录结构: +``` +backend/src/core/config/static/notification/ +├── __init__.py +├── static_schema.py +├── static_sync.py +└── notifications/ + ├── README.md + └── welcome_points.yaml +``` + +目标路径:`backend/src/core/config/static/notifications/welcome_points.yaml` + +同时 `backend/src/core/config/notification/` 包(Python package)需要重新审视命名: +- 该 package 本身只有 `__init__.py`、`static_schema.py`、`static_sync.py` 三个文件 +- package 名 `notification` 和 YAML 目录 `notifications` 容易混淆 + +### 实现方案 + +1. **重命名目录**:将 `backend/src/core/config/static/notification/notifications/` 移动到 `backend/src/core/config/static/notifications/` +2. **更新 `utils/paths.py`**:`get_notification_config_dir()` 返回 `get_static_config_dir() / "notifications"` +3. **保留 `backend/src/core/config/notification/` package 不变**(这是 Python 代码 package,不是配置目录) +4. 删除空出来的 `backend/src/core/config/static/notification/` 目录(仅含 `notifications/` 子目录) +5. 更新 README.md 的路径引用(如果有) + +### 涉及文件 + +- `backend/src/core/config/static/notification/notifications/` → `backend/src/core/config/static/notifications/` (git mv) +- `backend/src/utils/paths.py` — 修改 `get_notification_config_dir()` +- `backend/src/core/config/static/notification/` — 删除空目录 + +--- + +## 任务 4:通知 targets 语义约束与按用户类型分发 + +### 现状分析 + +**当前 `StaticNotificationTargets` schema**(`static_schema.py:32`): +```python +mode: Literal["all_users", "user_ids"] +user_ids: list[UUID] | None = None +``` + +**当前 YAML**(`welcome_points.yaml`): +```yaml +targets: + mode: all_users +``` + +**当前注册流程**(`auth/router.py:80-83`): +```python +notification_service = NotificationService(NotificationRepository(session)) +linked_count = await notification_service.link_published_notifications_to_user( + user_id=UUID(result.user.id) +) +``` + +`link_published_notifications_to_user` 无差别地将所有 `published` 状态的通知关联到新注册用户,不区分 target mode。 + +**`register_bonus_claims` 表**:通过 `email_hash` 记录首次注册奖励 claim。如果该邮箱已 claim 过,说明用户是"重新注册"的。 + +### 需求定义 + +targets mode 扩展为三种语义: + +| mode | 含义 | 新用户注册时 | 已有用户(sync 时) | +|------|------|-------------|-------------------| +| `new_users` | 仅推送给首次注册用户 | 注入 | 不推送 | +| `exist_users` | 仅推送给已存在的用户 | 不注入 | 推送 | +| `all_users` | 所有用户 | 注入 | 推送 | + +**关键判断逻辑**: +- "新用户"vs"重新注册用户"通过查询 `register_bonus_claims` 表判断 + - 如果该用户 email 在 `register_bonus_claims` 中已有记录且 `first_user_id_snapshot != 当前 user_id`,则为重新注册用户 +- `new_users`:仅首次注册(register_bonus_claims 中无记录)的用户 +- `exist_users`:已存在于数据库的用户(非注册时注入,而是通过 sync-notifications 或其他方式推送) + +### 实现方案 + +#### 4.1 扩展 `StaticNotificationTargets` schema + +```python +class StaticNotificationTargets(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + mode: Literal["new_users", "exist_users", "all_users", "user_ids"] + user_ids: list[UUID] | None = None + + @model_validator(mode="after") + def validate_target_mode(self) -> StaticNotificationTargets: + if self.mode in ("new_users", "exist_users", "all_users") and self.user_ids is not None: + raise ValueError("targets.user_ids must be absent when mode is not user_ids") + if self.mode == "user_ids": + if self.user_ids is None or len(self.user_ids) == 0: + raise ValueError("targets.user_ids must be a non-empty list when mode=user_ids") + return self +``` + +#### 4.2 修改 `welcome_points.yaml` + +```yaml +targets: + mode: new_users +``` + +#### 4.3 修改注册时通知分发逻辑 + +在 `auth/router.py` 的 `create_email_session` 中,替换当前的 `link_published_notifications_to_user` 调用: + +- 查询所有 `published` 状态且未删除的通知 +- 对每条通知,根据其 `targets.mode` 决定是否关联: + - `all_users`:始终关联 + - `new_users`:仅在用户是首次注册时关联(通过 register_bonus_claims 判断) + - `exist_users`:注册时不关联(此 mode 仅供 sync-notifications 使用) + - `user_ids`:如果 user_id 在列表中则关联 + +**判断是否首次注册**:复用 PointsService 的逻辑,通过 `register_bonus_claims` 表的 `email_hash` 查询是否已有 claim 记录。但需要注意时序——注册 bonus claim 和通知关联在同一个事务中。需要: +- 在 `grant_register_bonus_if_eligible` 之后判断该 claim 的结果 +- 如果 `granted=True`,说明是首次注册 → 新用户 +- 如果 `granted=False` 且 claim 已存在 → 重新注册用户 + +具体方案:让 `grant_register_bonus_if_eligible` 返回的结果已经包含判断信息(当前返回 `RegisterBonusResult`),可以用 `granted` 字段推断。但更精确的做法是在 `RegisterBonusResult` 中添加 `is_first_registration: bool` 字段。 + +或者更简洁:在 `NotificationRepository` 中新增方法 `link_notifications_for_new_user`,接收 `is_first_registration: bool` 参数,按 target mode 过滤通知后关联。 + +#### 4.4 修改 `static_sync.py` 的 `_resolve_target_user_ids` + +当前 `all_users` mode 返回所有 `AuthUser.id`。需要根据新语义调整: + +- `all_users`:返回所有 auth users +- `new_users`:返回在 `register_bonus_claims` 中**没有** claim 记录的用户的 `first_user_id_snapshot`(首次注册的用户) +- `exist_users`:返回所有 auth users 减去 new_users +- `user_ids`:保持不变 + +#### 4.5 通知与 target mode 的关联方式 + +通知的 target mode 存储在 YAML 中,但 sync 到数据库时 `Notification` 表本身没有 `target_mode` 字段。需要: +- 方案 A:在 `notifications` 表中添加 `target_mode` 列,sync 时写入 +- 方案 B:每次分发时重新读取 YAML 配置 + +推荐 **方案 A**,因为: +1. 分发逻辑(注册、sync)需要高效查询,不应每次都读文件 +2. target mode 是通知的固有属性,应持久化 + +### 实现步骤清单 + +1. 在 `notifications` 表新增 `target_mode` 列(Alembic migration) +2. 更新 `Notification` ORM model,添加 `target_mode` 字段 +3. 更新 `StaticNotificationTargets.mode` 的 Literal 类型,加入 `new_users` 和 `exist_users` +4. 更新 `StaticNotificationDefinition` 或 sync 逻辑,将 target_mode 写入 Notification 记录 +5. 修改 `welcome_points.yaml` 的 targets.mode 为 `new_users` +6. 在 `NotificationRepository` 中新增 `link_notifications_for_registered_user` 方法 +7. 修改 `auth/router.py` 注册流程,使用新的分发逻辑 +8. 更新 `_resolve_target_user_ids` 以支持新的 mode 语义 + +### 涉及文件 + +- `backend/alembic/versions/` — 新增 migration(notifications.target_mode) +- `backend/src/models/notification.py` — 添加 target_mode 字段 +- `backend/src/core/config/notification/static_schema.py` — 扩展 StaticNotificationTargets +- `backend/src/core/config/notification/static_sync.py` — sync 时写入 target_mode,修改 _resolve_target_user_ids +- `backend/src/core/config/static/notifications/welcome_points.yaml` — mode 改为 new_users +- `backend/src/v1/notifications/repository.py` — 新增按 target_mode 过滤的关联方法 +- `backend/src/v1/notifications/service.py` — 新增服务方法 +- `backend/src/v1/auth/router.py` — 修改注册流程调用 + +--- + +## 执行顺序 + +四个任务相互独立,按风险从低到高执行: + +1. **任务 1**(重复索引清理)— 风险最低,纯 DDL +2. **任务 3**(路径缩短)— 低风险,文件移动 + 一行代码修改 +3. **任务 2**(脚本合并)— 低风险,脚本层修改 + 删除文件 +4. **任务 4**(targets 语义)— 中风险,涉及 schema 变更、DB migration、业务逻辑修改 + +--- + +## 验证策略 + +- 任务 1:迁移后查询 `pg_indexes` 确认冗余索引已删除 +- 任务 2:运行 `./infra/scripts/dev-migrate.sh sync-notifications --dry-run` 验证子命令可用;运行 `./infra/scripts/dev-migrate.sh bootstrap` 验证包含通知同步 +- 任务 3:运行 sync-notifications 验证 YAML 加载路径正确 +- 任务 4: + - 单元测试:`StaticNotificationTargets` 的验证逻辑(new_users/exist_users/all_users/user_ids) + - 集成测试:新用户注册时只收到 `new_users` + `all_users` 通知 + - 集成测试:重新注册用户不收到 `new_users` 通知 + - sync-notifications 按 target_mode 正确分发 + +--- + +## 不在范围内 + +- 前端通知展示逻辑变更 +- 通知的推送(push notification)机制 +- 通知模板引擎或多语言支持 diff --git a/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/task.json b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/task.json new file mode 100644 index 0000000..f14451a --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-16-fix-notification-targets-cleanup/task.json @@ -0,0 +1,44 @@ +{ + "id": "fix-notification-targets-cleanup", + "name": "fix-notification-targets-cleanup", + "title": "修复通知 targets 约束、合并注册脚本、路径缩短及清理重复索引", + "description": "1) 分析并修复 llm_factory 和 llms 表的重复索引; 2) 将 register-notifications.sh 功能合并到 dev-migrate.sh 作为子命令并合入 bootstrap; 3) 缩短 notification 路径; 4) 为 notification targets 添加 pydantic schema 约束", + "status": "completed", + "dev_type": null, + "scope": null, + "priority": "P2", + "creator": "opencode", + "assignee": "opencode", + "createdAt": "2026-04-16", + "completedAt": "2026-04-16", + "branch": "worktree/fix-notification-targets-cleanup", + "base_branch": "worktree/fix-notification-targets-cleanup", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py b/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py new file mode 100644 index 0000000..1e36ddc --- /dev/null +++ b/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py @@ -0,0 +1,25 @@ +"""drop duplicate indexes on llm_factory.name and llms.model_code + +Revision ID: 20260416_0002 +Revises: 20260416_0001 +Create Date: 2026-04-16 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260416_0002" +down_revision: Union[str, Sequence[str], None] = "20260416_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_index("ix_llm_factory_name", table_name="llm_factory") + op.drop_index("ix_llms_model_code", table_name="llms") + + +def downgrade() -> None: + op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) + op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True) diff --git a/backend/alembic/versions/20260416_0003_add_notification_target_mode.py b/backend/alembic/versions/20260416_0003_add_notification_target_mode.py new file mode 100644 index 0000000..6bf1711 --- /dev/null +++ b/backend/alembic/versions/20260416_0003_add_notification_target_mode.py @@ -0,0 +1,37 @@ +"""add target_mode to notifications + +Revision ID: 20260416_0003 +Revises: 20260416_0002 +Create Date: 2026-04-16 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "20260416_0003" +down_revision: Union[str, Sequence[str], None] = "20260416_0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "notifications", + sa.Column( + "target_mode", + sa.String(32), + nullable=False, + server_default="all_users", + ), + ) + op.execute( + "ALTER TABLE notifications ADD CONSTRAINT ck_notifications_target_mode " + "CHECK (target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids'))" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE notifications DROP CONSTRAINT ck_notifications_target_mode") + op.drop_column("notifications", "target_mode") diff --git a/backend/src/core/config/notification/static_schema.py b/backend/src/core/config/notification/static_schema.py index c5e81fb..0479012 100644 --- a/backend/src/core/config/notification/static_schema.py +++ b/backend/src/core/config/notification/static_schema.py @@ -13,6 +13,7 @@ from backend.src.schemas.shared.notification import ( NotificationPayload, NotificationPayloadNone, ) +from schemas.enums import NotificationTargetMode class StaticNotificationDefinition(BaseModel): @@ -32,14 +33,16 @@ class StaticNotificationDefinition(BaseModel): class StaticNotificationTargets(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - mode: Literal["all_users", "user_ids"] + mode: NotificationTargetMode user_ids: list[UUID] | None = None @model_validator(mode="after") def validate_target_mode(self) -> StaticNotificationTargets: - if self.mode == "all_users" and self.user_ids is not None: - raise ValueError("targets.user_ids must be absent when mode=all_users") - if self.mode == "user_ids": + if self.mode != NotificationTargetMode.USER_IDS and self.user_ids is not None: + raise ValueError( + "targets.user_ids must be absent when mode is not user_ids" + ) + if self.mode == NotificationTargetMode.USER_IDS: if self.user_ids is None or len(self.user_ids) == 0: raise ValueError( "targets.user_ids must be a non-empty list when mode=user_ids" diff --git a/backend/src/core/config/notification/static_sync.py b/backend/src/core/config/notification/static_sync.py index 06023bb..9c62c2a 100644 --- a/backend/src/core/config/notification/static_sync.py +++ b/backend/src/core/config/notification/static_sync.py @@ -23,7 +23,9 @@ from core.config.notification.static_schema import ( ) from models.auth_user import AuthUser from models.notification import Notification +from models.register_bonus_claims import RegisterBonusClaims from models.user_notification import UserNotification +from schemas.enums import NotificationTargetMode from utils.paths import get_notification_config_dir logger = get_logger("core.config.notification.static_sync") @@ -203,6 +205,7 @@ async def _sync_document( body=definition.body, payload=_payload_to_dict(definition.payload), status=definition.status, + target_mode=document.config.targets.mode, published_at=_resolve_published_at(existing=None, config=definition), revoked_at=_resolve_revoked_at(existing=None, config=definition), deleted_at=_resolve_deleted_at(existing=None, config=definition), @@ -219,6 +222,7 @@ async def _sync_document( notification=notification, config=definition, content_hash=content_hash, + target_mode=document.config.targets.mode, ) if changed: updated = 1 @@ -373,7 +377,11 @@ def _resolve_deleted_at( def _apply_notification_updates( - *, notification: Notification, config: object, content_hash: str + *, + notification: Notification, + config: object, + content_hash: str, + target_mode: NotificationTargetMode, ) -> bool: next_values = { "type": getattr(config, "type"), @@ -383,6 +391,7 @@ def _apply_notification_updates( "body": getattr(config, "body"), "payload": _payload_to_dict(getattr(config, "payload")), "status": getattr(config, "status"), + "target_mode": target_mode, "published_at": _resolve_published_at(existing=notification, config=config), "revoked_at": _resolve_revoked_at(existing=notification, config=config), "deleted_at": _resolve_deleted_at(existing=notification, config=config), @@ -399,7 +408,7 @@ async def _resolve_target_user_ids( *, session: AsyncSession, config: StaticNotificationFile ) -> list[UUID]: targets = config.targets - if targets.mode == "user_ids": + if targets.mode == NotificationTargetMode.USER_IDS: requested_user_ids = list(dict.fromkeys(targets.user_ids or [])) result = await session.execute( select(AuthUser.id).where(AuthUser.id.in_(requested_user_ids)) @@ -416,6 +425,21 @@ async def _resolve_target_user_ids( + ", ".join(sorted(missing_user_ids)) ) return requested_user_ids + if targets.mode in ( + NotificationTargetMode.NEW_USERS, + NotificationTargetMode.EXIST_USERS, + ): + claimed_result = await session.execute( + select(RegisterBonusClaims.first_user_id_snapshot).where( + RegisterBonusClaims.first_user_id_snapshot.isnot(None) + ) + ) + claimed_ids = set(claimed_result.scalars().all()) + all_users_result = await session.execute(select(AuthUser.id)) + all_user_ids = all_users_result.scalars().all() + if targets.mode == NotificationTargetMode.NEW_USERS: + return [uid for uid in all_user_ids if uid not in claimed_ids] + return [uid for uid in all_user_ids if uid in claimed_ids] result = await session.execute(select(AuthUser.id)) return list(result.scalars().all()) diff --git a/backend/src/core/config/static/notification/notifications/README.md b/backend/src/core/config/static/notifications/README.md similarity index 100% rename from backend/src/core/config/static/notification/notifications/README.md rename to backend/src/core/config/static/notifications/README.md diff --git a/backend/src/core/config/static/notification/notifications/welcome_points.yaml b/backend/src/core/config/static/notifications/welcome_points.yaml similarity index 93% rename from backend/src/core/config/static/notification/notifications/welcome_points.yaml rename to backend/src/core/config/static/notifications/welcome_points.yaml index c066140..d6c4a61 100644 --- a/backend/src/core/config/static/notification/notifications/welcome_points.yaml +++ b/backend/src/core/config/static/notifications/welcome_points.yaml @@ -11,4 +11,4 @@ notification: tab: balance targets: - mode: all_users + mode: new_users diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index 5b66397..32927fa 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -84,7 +84,7 @@ async def run_init_data() -> bool: async def bootstrap() -> bool: - logger.info("Starting bootstrap (migrate + init-data)") + logger.info("Starting bootstrap (migrate + init-data + sync-notifications)") if not run_migrations(): logger.error("Bootstrap aborted: migrations failed") @@ -94,6 +94,10 @@ async def bootstrap() -> bool: logger.error("Bootstrap aborted: init-data failed") return False + if not await run_sync_notifications(): + logger.error("Bootstrap aborted: sync-notifications failed") + return False + logger.info("Bootstrap completed successfully") return True diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py index 7a37ff1..db5a976 100644 --- a/backend/src/models/notification.py +++ b/backend/src/models/notification.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column from core.db.base import Base, SoftDeleteMixin, TimestampMixin from core.db.types import json_jsonb +from schemas.enums import NotificationTargetMode class Notification(TimestampMixin, SoftDeleteMixin, Base): @@ -18,6 +19,10 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base): "status IN ('draft', 'published', 'revoked')", name="ck_notifications_status", ), + CheckConstraint( + "target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')", + name="ck_notifications_target_mode", + ), CheckConstraint( "jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object", @@ -63,6 +68,9 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base): status: Mapped[str] = mapped_column( String(16), nullable=False, server_default=text("'published'") ) + target_mode: Mapped[NotificationTargetMode] = mapped_column( + String(32), nullable=False, server_default=text("'all_users'") + ) published_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/backend/src/schemas/enums.py b/backend/src/schemas/enums.py index 286f4d9..a87615a 100644 --- a/backend/src/schemas/enums.py +++ b/backend/src/schemas/enums.py @@ -151,3 +151,10 @@ class GroupMemberStatus(str, Enum): ACTIVE = "active" MUTED = "muted" REMOVED = "removed" + + +class NotificationTargetMode(str, Enum): + NEW_USERS = "new_users" + EXIST_USERS = "exist_users" + ALL_USERS = "all_users" + USER_IDS = "user_ids" diff --git a/backend/src/utils/paths.py b/backend/src/utils/paths.py index 8a7b0cc..61704a9 100644 --- a/backend/src/utils/paths.py +++ b/backend/src/utils/paths.py @@ -24,7 +24,7 @@ def get_database_config_dir() -> Path: def get_notification_config_dir() -> Path: - return get_static_config_dir() / "notification/notifications" + return get_static_config_dir() / "notifications" def get_divination_data_dir() -> Path: diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index bc2bdfa..de106ad 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -73,13 +73,14 @@ async def create_email_session( ) result = await service.create_email_session(payload) points_service = PointsService(repository=PointsRepository(session)) - await points_service.grant_register_bonus_if_eligible( + bonus_result = await points_service.grant_register_bonus_if_eligible( user_id=UUID(result.user.id), user_email=result.user.email, ) notification_service = NotificationService(NotificationRepository(session)) - linked_count = await notification_service.link_published_notifications_to_user( - user_id=UUID(result.user.id) + linked_count = await notification_service.link_notifications_for_registered_user( + user_id=UUID(result.user.id), + is_first_registration=bonus_result.is_first_registration, ) await session.commit() logger.info( diff --git a/backend/src/v1/notifications/repository.py b/backend/src/v1/notifications/repository.py index ee702be..726b0a6 100644 --- a/backend/src/v1/notifications/repository.py +++ b/backend/src/v1/notifications/repository.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.notification import Notification from models.user_notification import UserNotification +from schemas.enums import NotificationTargetMode class NotificationRepository: @@ -116,13 +117,24 @@ class NotificationRepository: async def commit(self) -> None: await self._session.commit() - async def link_published_notifications_to_user(self, *, user_id: UUID) -> int: + async def link_notifications_for_registered_user( + self, *, user_id: UUID, is_first_registration: bool + ) -> int: + target_modes: list[NotificationTargetMode] + if is_first_registration: + target_modes = [ + NotificationTargetMode.NEW_USERS, + NotificationTargetMode.ALL_USERS, + ] + else: + target_modes = [NotificationTargetMode.ALL_USERS] notification_ids = list( ( await self._session.execute( select(Notification.id).where( Notification.status == "published", Notification.deleted_at.is_(None), + Notification.target_mode.in_(target_modes), ) ) ) @@ -136,8 +148,8 @@ class NotificationRepository: insert(UserNotification) .values( [ - {"user_id": user_id, "notification_id": notification_id} - for notification_id in notification_ids + {"user_id": user_id, "notification_id": nid} + for nid in notification_ids ] ) .on_conflict_do_nothing(index_elements=["user_id", "notification_id"]) diff --git a/backend/src/v1/notifications/service.py b/backend/src/v1/notifications/service.py index 317e7da..3c48537 100644 --- a/backend/src/v1/notifications/service.py +++ b/backend/src/v1/notifications/service.py @@ -123,9 +123,11 @@ class NotificationService: await self._repository.commit() return updated_count - async def link_published_notifications_to_user(self, *, user_id: UUID) -> int: - return await self._repository.link_published_notifications_to_user( - user_id=user_id + async def link_notifications_for_registered_user( + self, *, user_id: UUID, is_first_registration: bool + ) -> int: + return await self._repository.link_notifications_for_registered_user( + user_id=user_id, is_first_registration=is_first_registration ) diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index c65501a..02b5eee 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -61,6 +61,7 @@ class RegisterBonusResult: amount: int balance_after: int event_id: str + is_first_registration: bool = False @dataclass(frozen=True) @@ -122,14 +123,17 @@ class PointsService: account = await self._repository.get_or_create_user_points_for_update( user_id=user_id ) - if claim is not None and claim.balance_snapshot is not None: - account.balance = max(int(claim.balance_snapshot), 0) - account.version = int(account.version) + 1 + if claim is not None: + is_first_registration = claim.first_user_id_snapshot is None + if claim.balance_snapshot is not None: + account.balance = max(int(claim.balance_snapshot), 0) + account.version = int(account.version) + 1 return RegisterBonusResult( granted=False, amount=0, balance_after=int(account.balance), event_id=event_id, + is_first_registration=is_first_registration, ) claimed = await self._repository.claim_register_bonus( @@ -144,6 +148,7 @@ class PointsService: amount=0, balance_after=int(account.balance), event_id=event_id, + is_first_registration=False, ) balance = int(account.balance) @@ -197,6 +202,7 @@ class PointsService: amount=bonus_points, balance_after=int(account.balance), event_id=event_id, + is_first_registration=True, ) async def ensure_run_points_available( diff --git a/backend/tests/integration/test_notification_target_mode.py b/backend/tests/integration/test_notification_target_mode.py new file mode 100644 index 0000000..f5480ec --- /dev/null +++ b/backend/tests/integration/test_notification_target_mode.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import time +from typing import TypedDict +from uuid import UUID + +import httpx +import pytest +from sqlalchemy import select + +from core.db.session import AsyncSessionLocal +from models.notification import Notification +from models.user_notification import UserNotification + + +class IdentityData(TypedDict): + email: str + code: str + + +async def _create_email_session( + client: httpx.AsyncClient, + *, + email: str, + code: str, +) -> dict[str, object]: + resp = await client.post( + "/api/v1/auth/email-session", + json={"email": email, "token": code}, + ) + resp.raise_for_status() + return resp.json() + + +async def _delete_user(client: httpx.AsyncClient, *, token: str) -> None: + resp = await client.delete( + "/api/v1/users/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_notification_target_mode_first_reg_and_reregister( + api_client: httpx.AsyncClient, + test_identity: IdentityData, + db_cleanup: list[str], +) -> None: + email = str(test_identity["email"]).strip().lower() + db_cleanup.append(email) + + first = await _create_email_session( + api_client, email=email, code=str(test_identity["code"]) + ) + user1 = first.get("user") + assert isinstance(user1, dict) + user1_id = UUID(str(user1["id"])) + token1 = str(first["access_token"]) + + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Notification.target_mode) + .join(UserNotification, UserNotification.notification_id == Notification.id) + .where(UserNotification.user_id == user1_id) + .order_by(Notification.target_mode) + ) + first_target_modes = [str(row[0]) for row in result.all()] + + assert "new_users" in first_target_modes + assert "exist_users" not in first_target_modes + + await _delete_user(api_client, token=token1) + time.sleep(0.5) + + second = await _create_email_session( + api_client, email=email, code=str(test_identity["code"]) + ) + user2 = second.get("user") + assert isinstance(user2, dict) + user2_id = UUID(str(user2["id"])) + token2 = str(second["access_token"]) + + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Notification.target_mode) + .join(UserNotification, UserNotification.notification_id == Notification.id) + .where(UserNotification.user_id == user2_id) + .order_by(Notification.target_mode) + ) + second_target_modes = [str(row[0]) for row in result.all()] + + assert "new_users" not in second_target_modes + assert "all_users" not in second_target_modes + assert "exist_users" not in second_target_modes + + await _delete_user(api_client, token=token2) diff --git a/backend/tests/unit/test_notification_target_mode.py b/backend/tests/unit/test_notification_target_mode.py new file mode 100644 index 0000000..5b9f15c --- /dev/null +++ b/backend/tests/unit/test_notification_target_mode.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +import pytest + +from schemas.enums import NotificationTargetMode +from v1.notifications.service import NotificationService + + +class _FakeNotification: + def __init__( + self, + *, + id: UUID, + target_mode: NotificationTargetMode = NotificationTargetMode.ALL_USERS, + status: str = "published", + deleted_at: datetime | None = None, + ): + self.id = id + self.target_mode = target_mode + self.status = status + self.deleted_at = deleted_at + + +class _TrackingNotificationRepository: + def __init__(self, notifications: list[_FakeNotification]) -> None: + self._notifications = notifications + self.linked_notification_ids: list[list[UUID]] = [] + self.linked_is_first: list[bool] = [] + + async def link_notifications_for_registered_user( + self, *, user_id: UUID, is_first_registration: bool + ) -> int: + target_modes: list[NotificationTargetMode] + if is_first_registration: + target_modes = [ + NotificationTargetMode.NEW_USERS, + NotificationTargetMode.ALL_USERS, + ] + else: + target_modes = [NotificationTargetMode.ALL_USERS] + + matched = [ + n + for n in self._notifications + if n.status == "published" + and n.deleted_at is None + and n.target_mode in target_modes + ] + self.linked_notification_ids.append([n.id for n in matched]) + self.linked_is_first.append(is_first_registration) + return len(matched) + + +@pytest.fixture +def notification_new_users() -> _FakeNotification: + return _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.NEW_USERS) + + +@pytest.fixture +def notification_all_users() -> _FakeNotification: + return _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.ALL_USERS) + + +@pytest.fixture +def notification_exist_users() -> _FakeNotification: + return _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.EXIST_USERS) + + +class TestLinkNotificationsForRegisteredUser: + @pytest.mark.asyncio + async def test_first_registration_gets_new_users_and_all_users( + self, + notification_new_users: _FakeNotification, + notification_all_users: _FakeNotification, + notification_exist_users: _FakeNotification, + ) -> None: + repo = _TrackingNotificationRepository( + [notification_new_users, notification_all_users, notification_exist_users] + ) + service = NotificationService(repository=repo) # type: ignore[arg-type] + + count = await service.link_notifications_for_registered_user( + user_id=uuid4(), is_first_registration=True + ) + + assert count == 2 + linked_ids = repo.linked_notification_ids[0] + assert notification_new_users.id in linked_ids + assert notification_all_users.id in linked_ids + assert notification_exist_users.id not in linked_ids + + @pytest.mark.asyncio + async def test_reregistered_user_only_gets_all_users( + self, + notification_new_users: _FakeNotification, + notification_all_users: _FakeNotification, + notification_exist_users: _FakeNotification, + ) -> None: + repo = _TrackingNotificationRepository( + [notification_new_users, notification_all_users, notification_exist_users] + ) + service = NotificationService(repository=repo) # type: ignore[arg-type] + + count = await service.link_notifications_for_registered_user( + user_id=uuid4(), is_first_registration=False + ) + + assert count == 1 + linked_ids = repo.linked_notification_ids[0] + assert notification_new_users.id not in linked_ids + assert notification_all_users.id in linked_ids + assert notification_exist_users.id not in linked_ids + + @pytest.mark.asyncio + async def test_no_published_notifications_returns_zero(self) -> None: + repo = _TrackingNotificationRepository([]) + service = NotificationService(repository=repo) # type: ignore[arg-type] + + count = await service.link_notifications_for_registered_user( + user_id=uuid4(), is_first_registration=True + ) + + assert count == 0 + + @pytest.mark.asyncio + async def test_only_new_users_notification_first_registration(self) -> None: + n = _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.NEW_USERS) + repo = _TrackingNotificationRepository([n]) + service = NotificationService(repository=repo) # type: ignore[arg-type] + + count = await service.link_notifications_for_registered_user( + user_id=uuid4(), is_first_registration=True + ) + + assert count == 1 + + @pytest.mark.asyncio + async def test_only_new_users_notification_reregistered(self) -> None: + n = _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.NEW_USERS) + repo = _TrackingNotificationRepository([n]) + service = NotificationService(repository=repo) # type: ignore[arg-type] + + count = await service.link_notifications_for_registered_user( + user_id=uuid4(), is_first_registration=False + ) + + assert count == 0 diff --git a/backend/tests/unit/test_register_bonus_first_registration.py b/backend/tests/unit/test_register_bonus_first_registration.py new file mode 100644 index 0000000..499da0a --- /dev/null +++ b/backend/tests/unit/test_register_bonus_first_registration.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from models.register_bonus_claims import RegisterBonusClaims +from v1.points.service import PointsService + + +class _FakeAccount: + balance: int = 100 + frozen_balance: int = 0 + lifetime_earned: int = 0 + lifetime_spent: int = 0 + version: int = 0 + + +class _FakePointsRepository: + def __init__(self, *, claim: RegisterBonusClaims | None = None) -> None: + self.account = _FakeAccount() + self.claim = claim + self.claimed = False + self.appended_ledger: list[object] = [] + self.appended_audit: list[object] = [] + + async def get_or_create_user_points_for_update( + self, *, user_id: object + ) -> _FakeAccount: + return self.account + + async def has_ledger_event(self, *, user_id: object, event_id: str) -> bool: + return False + + async def append_ledger(self, *, command: object, balance_after: int) -> None: + self.appended_ledger.append(command) + + async def append_audit_ledger(self, *, command: object) -> None: + self.appended_audit.append(command) + + async def has_audit_event(self, *, event_id: str) -> bool: + return False + + async def claim_register_bonus( + self, + *, + email_hash: str, + user_email_snapshot: str, + first_user_id_snapshot: object, + grant_event_id: str, + ) -> bool: + if self.claimed: + return False + self.claimed = True + return True + + async def get_register_bonus_claim( + self, *, email_hash: str + ) -> RegisterBonusClaims | None: + return self.claim + + +class TestRegisterBonusIsFirstRegistration: + @pytest.mark.asyncio + async def test_first_registration_sets_true(self) -> None: + repo = _FakePointsRepository(claim=None) + service = PointsService(repository=repo) # type: ignore[arg-type] + + result = await service.grant_register_bonus_if_eligible( + user_id=uuid4(), user_email="new@example.com" + ) + + assert result.granted is True + assert result.is_first_registration is True + + @pytest.mark.asyncio + async def test_reregistered_with_existing_claim_sets_false(self) -> None: + existing_claim = RegisterBonusClaims( + email_hash="abc", + user_email_snapshot="old@example.com", + first_user_id_snapshot=uuid4(), + balance_snapshot=50, + grant_event_id="evt", + ) + repo = _FakePointsRepository(claim=existing_claim) + service = PointsService(repository=repo) # type: ignore[arg-type] + + result = await service.grant_register_bonus_if_eligible( + user_id=uuid4(), user_email="old@example.com" + ) + + assert result.granted is False + assert result.is_first_registration is False + + @pytest.mark.asyncio + async def test_reregistered_claim_without_first_user_id_sets_true(self) -> None: + claim_no_snapshot = RegisterBonusClaims( + email_hash="abc", + user_email_snapshot="edge@example.com", + first_user_id_snapshot=None, + balance_snapshot=50, + grant_event_id="evt", + ) + repo = _FakePointsRepository(claim=claim_no_snapshot) + service = PointsService(repository=repo) # type: ignore[arg-type] + + result = await service.grant_register_bonus_if_eligible( + user_id=uuid4(), user_email="edge@example.com" + ) + + assert result.granted is False + assert result.is_first_registration is True + + @pytest.mark.asyncio + async def test_claim_competition_failure_sets_false(self) -> None: + repo = _FakePointsRepository(claim=None) + repo.claimed = True + service = PointsService(repository=repo) # type: ignore[arg-type] + + result = await service.grant_register_bonus_if_eligible( + user_id=uuid4(), user_email="race@example.com" + ) + + assert result.granted is False + assert result.is_first_registration is False diff --git a/backend/tests/unit/test_static_notification_sync.py b/backend/tests/unit/test_static_notification_sync.py index 3548fdf..2267624 100644 --- a/backend/tests/unit/test_static_notification_sync.py +++ b/backend/tests/unit/test_static_notification_sync.py @@ -10,6 +10,7 @@ from core.config.notification.static_sync import ( build_static_notification_content_hash, load_static_notification_documents, ) +from schemas.enums import NotificationTargetMode def _write_yaml(path: Path, content: str) -> None: @@ -43,10 +44,86 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None assert loaded.notification.source_key == "welcome_bonus" assert loaded.notification.payload.action == "open_route" - assert loaded.targets.mode == "user_ids" + assert loaded.targets.mode == NotificationTargetMode.USER_IDS assert len(loaded.targets.user_ids or []) == 1 +def test_load_static_notification_file_parses_new_users(tmp_path: Path) -> None: + file_path = tmp_path / "welcome_points.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: welcome_points + version: 1 + type: system + status: published + title: Welcome + body: You got points. + payload: + action: open_route + route: /points + tab: balance + targets: + mode: new_users + """, + ) + + loaded = load_static_notification_file(file_path) + + assert loaded.targets.mode == NotificationTargetMode.NEW_USERS + assert loaded.targets.user_ids is None + + +def test_load_static_notification_file_parses_exist_users(tmp_path: Path) -> None: + file_path = tmp_path / "promo.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: promo_return + version: 1 + type: system + status: published + title: Come back + body: We miss you. + payload: + action: none + targets: + mode: exist_users + """, + ) + + loaded = load_static_notification_file(file_path) + + assert loaded.targets.mode == NotificationTargetMode.EXIST_USERS + assert loaded.targets.user_ids is None + + +def test_load_static_notification_file_parses_all_users(tmp_path: Path) -> None: + file_path = tmp_path / "announce.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: system_announce + version: 1 + type: system + status: published + title: Announcement + body: Maintenance at midnight. + payload: + action: none + targets: + mode: all_users + """, + ) + + loaded = load_static_notification_file(file_path) + + assert loaded.targets.mode == NotificationTargetMode.ALL_USERS + + def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -> None: file_path = tmp_path / "invalid.yaml" _write_yaml( @@ -72,6 +149,81 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) - load_static_notification_file(file_path) +def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> None: + file_path = tmp_path / "bad_mode.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: bad_mode + version: 1 + type: system + status: published + title: Bad + body: Bad mode. + payload: + action: none + targets: + mode: non_existent + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + +def test_load_static_notification_file_rejects_new_users_with_user_ids( + tmp_path: Path, +) -> None: + file_path = tmp_path / "bad_new_users.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: bad_new + version: 1 + type: system + status: published + title: Bad + body: Bad. + payload: + action: none + targets: + mode: new_users + user_ids: + - 11111111-1111-1111-1111-111111111111 + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + +def test_load_static_notification_file_rejects_user_ids_without_list( + tmp_path: Path, +) -> None: + file_path = tmp_path / "bad_user_ids.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: bad_uids + version: 1 + type: system + status: published + title: Bad + body: Bad. + payload: + action: none + targets: + mode: user_ids + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + def test_load_static_notification_documents_rejects_duplicate_source_key( tmp_path: Path, ) -> None: diff --git a/docs/protocols/notification/static-notification-sync-protocol.md b/docs/protocols/notification/static-notification-sync-protocol.md index cfa2e4b..7fe1bc8 100644 --- a/docs/protocols/notification/static-notification-sync-protocol.md +++ b/docs/protocols/notification/static-notification-sync-protocol.md @@ -212,10 +212,10 @@ Supported options: Infra wrapper: ```bash -./infra/scripts/register-notifications.sh -./infra/scripts/register-notifications.sh --dry-run -./infra/scripts/register-notifications.sh --source-key welcome_bonus -./infra/scripts/register-notifications.sh --prune --reconcile-targets +./infra/scripts/dev-migrate.sh sync-notifications +./infra/scripts/dev-migrate.sh sync-notifications -- --dry-run +./infra/scripts/dev-migrate.sh sync-notifications -- --source-key welcome_bonus +./infra/scripts/dev-migrate.sh sync-notifications -- --prune --reconcile-targets ``` ## Failure behavior diff --git a/infra/scripts/dev-migrate.sh b/infra/scripts/dev-migrate.sh index a749a9f..2b00643 100755 --- a/infra/scripts/dev-migrate.sh +++ b/infra/scripts/dev-migrate.sh @@ -6,12 +6,13 @@ ENV_FILE="$ROOT_DIR/.env" ENV_LOADER="$ROOT_DIR/infra/scripts/lib/env.sh" usage() { - echo "Usage: $0 {migrate|init-data|bootstrap}" + echo "Usage: $0 {migrate|init-data|sync-notifications|bootstrap}" echo "" echo "Commands:" - echo " migrate Run database migrations only" - echo " init-data Initialize seed data only" - echo " bootstrap Run migrations + init-data" + echo " migrate Run database migrations only" + echo " init-data Initialize seed data only" + echo " sync-notifications Sync static notification configs to DB" + echo " bootstrap Run migrations + init-data + sync-notifications" echo "" echo "Note: Requires redis service running (docker compose up -d redis)" exit 1 @@ -37,6 +38,11 @@ case "${1:-}" in echo "=== Running Init Data ===" PYTHONPATH=backend/src uv run python -m core.runtime.cli init-data ;; + sync-notifications) + shift + echo "=== Running Sync Notifications ===" + PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications "$@" + ;; bootstrap) echo "=== Running Bootstrap ===" PYTHONPATH=backend/src uv run python -m core.runtime.cli bootstrap diff --git a/infra/scripts/register-notifications.sh b/infra/scripts/register-notifications.sh deleted file mode 100755 index 9f70151..0000000 --- a/infra/scripts/register-notifications.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" -ENV_FILE="$ROOT_DIR/.env" -ENV_LOADER="$ROOT_DIR/infra/scripts/lib/env.sh" - -if [ ! -f "$ENV_FILE" ]; then - echo "Error: env file not found at $ENV_FILE" >&2 - exit 1 -fi - -# shellcheck disable=SC1090 -. "$ENV_LOADER" -load_env_file "$ENV_FILE" - -cd "$ROOT_DIR" - -PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications "$@"