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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user