feat: replace email flows with in-app notifications

Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
This commit is contained in:
2026-09-26 12:23:10 +03:30
parent b706a2d10a
commit 3ec28638d1
54 changed files with 787 additions and 4843 deletions

View File

@@ -50,16 +50,22 @@ class RedisNotificationStore:
"type": payload.get("type") or "notification",
"title": payload.get("title") or "",
"message": payload.get("message") or "",
"body_markdown": payload.get("body_markdown") or None,
"level": payload.get("level") or "info",
"created_at": _isoformat_datetime(payload.get("created_at")),
"is_seen": bool(payload.get("is_seen", False)),
"delete_on_seen": bool(payload.get("delete_on_seen", False)),
"action_url": payload.get("action_url"),
"entity_type": payload.get("entity_type"),
"entity_id": payload.get("entity_id"),
"meta": payload.get("meta") or {},
}
@staticmethod
def summary(data: dict) -> dict:
result = {**data, "has_body": bool(data.get("body_markdown"))}
result.pop("body_markdown", None)
return result
@classmethod
def _publish_event(cls, user_id: str, event: str, data: dict) -> None:
if not settings.NOTIFICATIONS_ENABLED:
@@ -96,7 +102,7 @@ class RedisNotificationStore:
pipe.execute()
unread_count = cls.unread_count(user_id)
cls._publish_event(user_id, "notification", {"notification": data, "unread_count": unread_count})
cls._publish_event(user_id, "notification", {"notification": cls.summary(data), "unread_count": unread_count})
cls._publish_event(user_id, "unread_count", {"unread_count": unread_count})
return data
@@ -108,6 +114,7 @@ class RedisNotificationStore:
limit: int | None = None,
offset: int = 0,
type_filter: str | None = None,
read_filter: str = "all",
paginate: bool = True,
) -> tuple[list[dict], int]:
ids_key = cls._ids_key(user_id)
@@ -134,6 +141,10 @@ class RedisNotificationStore:
continue
if type_filter and data.get("type") != type_filter:
continue
if read_filter == "unread" and data["is_seen"]:
continue
if read_filter == "read" and not data["is_seen"]:
continue
items.append(data)
if cleanup_ids:
@@ -182,17 +193,6 @@ class RedisNotificationStore:
data = cls.get(user_id, notif_id)
if not data:
return None
if data.get("delete_on_seen"):
deleted = cls.delete(user_id, notif_id)
if deleted:
return {
"notification_id": notif_id,
"deleted": True,
"notification": None,
"unread_count": cls.unread_count(user_id),
}
return None
if not data.get("is_seen"):
data["is_seen"] = True
redis_client.hset(
@@ -204,7 +204,7 @@ class RedisNotificationStore:
payload = {
"notification_id": notif_id,
"deleted": False,
"notification": data,
"notification": cls.summary(data),
"unread_count": unread_count,
}
cls._publish_event(user_id, "notification_seen", payload)
@@ -228,16 +228,12 @@ class RedisNotificationStore:
continue
if type_filter and data.get("type") != type_filter:
continue
if data.get("delete_on_seen"):
pipe.zrem(cls._ids_key(user_id), notif_id)
pipe.hdel(cls._data_key(user_id), notif_id)
else:
data["is_seen"] = True
pipe.hset(
cls._data_key(user_id),
notif_id,
json.dumps(data, ensure_ascii=False, default=str),
)
data["is_seen"] = True
pipe.hset(
cls._data_key(user_id),
notif_id,
json.dumps(data, ensure_ascii=False, default=str),
)
updated += 1
if updated:
pipe.execute()