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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"}
|
||||
@@ -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)机制
|
||||
- 通知模板引擎或多语言支持
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ notification:
|
||||
tab: balance
|
||||
|
||||
targets:
|
||||
mode: all_users
|
||||
mode: new_users
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user