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:
qzl
2026-04-16 17:48:36 +08:00
parent d91064835b
commit c79c773d67
26 changed files with 1011 additions and 49 deletions
@@ -0,0 +1 @@
{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"}
@@ -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 migrationdrop 冗余索引:
- `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/` — 新增 migrationnotifications.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())
@@ -11,4 +11,4 @@ notification:
tab: balance
targets:
mode: all_users
mode: new_users
+5 -1
View File
@@ -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
+8
View File
@@ -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
)
+7
View File
@@ -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"
+1 -1
View File
@@ -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:
+4 -3
View File
@@ -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(
+15 -3
View File
@@ -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"])
+5 -3
View File
@@ -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
)
+9 -3
View File
@@ -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
+10 -4
View File
@@ -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
-19
View File
@@ -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 "$@"