2026-06-29 00:10:10 +02:00
|
|
|
"""Notification service — create and manage user notifications."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
2026-06-29 17:43:56 +02:00
|
|
|
from datetime import UTC
|
2026-06-29 00:10:10 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-07-15 21:00:32 +02:00
|
|
|
from sqlalchemy import and_, func, select, update
|
2026-06-29 00:10:10 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-07-15 21:00:32 +02:00
|
|
|
from app.models.notification import (
|
|
|
|
|
Notification,
|
|
|
|
|
NotificationPreference,
|
|
|
|
|
NotificationType,
|
|
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_notification(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
type: str,
|
|
|
|
|
title: str,
|
|
|
|
|
body: str | None = None,
|
2026-07-15 21:00:32 +02:00
|
|
|
) -> Notification | None:
|
|
|
|
|
"""Create a new notification for a user if they have not disabled this type.
|
|
|
|
|
|
|
|
|
|
Returns None if the user has opted out of this notification type.
|
|
|
|
|
"""
|
|
|
|
|
# Check user preference
|
|
|
|
|
pref = await db.execute(
|
|
|
|
|
select(NotificationPreference).where(
|
|
|
|
|
and_(
|
|
|
|
|
NotificationPreference.user_id == user_id,
|
|
|
|
|
NotificationPreference.type_key == type,
|
|
|
|
|
NotificationPreference.tenant_id == tenant_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
pref_row = pref.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
# If preference exists and is disabled, skip
|
|
|
|
|
if pref_row and not pref_row.is_enabled:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# If no preference, check if type is enabled by default
|
|
|
|
|
if not pref_row:
|
|
|
|
|
type_def = await db.execute(
|
|
|
|
|
select(NotificationType).where(NotificationType.type_key == type)
|
|
|
|
|
)
|
|
|
|
|
type_row = type_def.scalar_one_or_none()
|
|
|
|
|
if type_row and not type_row.is_enabled_by_default:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Create notification
|
2026-06-29 00:10:10 +02:00
|
|
|
notif = Notification(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
type=type,
|
|
|
|
|
title=title,
|
|
|
|
|
body=body,
|
|
|
|
|
)
|
|
|
|
|
db.add(notif)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return notif
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_notifications(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
page: int = 1,
|
|
|
|
|
page_size: int = 25,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""List notifications for a user, unread first, then by created_at desc."""
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
# Count total
|
2026-06-29 17:43:56 +02:00
|
|
|
count_q = (
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(Notification)
|
|
|
|
|
.where(
|
|
|
|
|
Notification.tenant_id == tenant_id,
|
|
|
|
|
Notification.user_id == user_id,
|
|
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
)
|
|
|
|
|
total = (await db.execute(count_q)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
# Query — unread first (read_at IS NULL), then newest
|
|
|
|
|
q = (
|
|
|
|
|
select(Notification)
|
|
|
|
|
.where(
|
|
|
|
|
Notification.tenant_id == tenant_id,
|
|
|
|
|
Notification.user_id == user_id,
|
|
|
|
|
)
|
|
|
|
|
.order_by(
|
|
|
|
|
Notification.read_at.isnot(None), # False (unread) sorts first
|
|
|
|
|
Notification.created_at.desc(),
|
|
|
|
|
)
|
|
|
|
|
.offset(offset)
|
|
|
|
|
.limit(page_size)
|
|
|
|
|
)
|
|
|
|
|
result = await db.execute(q)
|
|
|
|
|
items = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"items": [_notification_to_dict(n) for n in items],
|
|
|
|
|
"total": total,
|
|
|
|
|
"page": page,
|
|
|
|
|
"page_size": page_size,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def mark_notification_read(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
notification_id: uuid.UUID,
|
|
|
|
|
) -> Notification | None:
|
|
|
|
|
"""Mark a notification as read."""
|
2026-06-29 17:43:56 +02:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
q = (
|
|
|
|
|
update(Notification)
|
|
|
|
|
.where(
|
|
|
|
|
Notification.id == notification_id,
|
|
|
|
|
Notification.tenant_id == tenant_id,
|
|
|
|
|
Notification.user_id == user_id,
|
|
|
|
|
)
|
2026-06-29 17:43:56 +02:00
|
|
|
.values(read_at=datetime.now(UTC))
|
2026-06-29 00:10:10 +02:00
|
|
|
.returning(Notification)
|
|
|
|
|
)
|
|
|
|
|
result = await db.execute(q)
|
|
|
|
|
row = result.scalar_one_or_none()
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_unread_count(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Get unread notification count for a user."""
|
2026-06-29 17:43:56 +02:00
|
|
|
q = (
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(Notification)
|
|
|
|
|
.where(
|
|
|
|
|
Notification.tenant_id == tenant_id,
|
|
|
|
|
Notification.user_id == user_id,
|
|
|
|
|
Notification.read_at.is_(None),
|
|
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
)
|
|
|
|
|
result = await db.execute(q)
|
|
|
|
|
return result.scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _notification_to_dict(n: Notification) -> dict[str, Any]:
|
|
|
|
|
"""Convert a notification to a dict."""
|
|
|
|
|
return {
|
|
|
|
|
"id": str(n.id),
|
|
|
|
|
"type": n.type,
|
|
|
|
|
"title": n.title,
|
|
|
|
|
"body": n.body,
|
|
|
|
|
"read_at": n.read_at.isoformat() if n.read_at else None,
|
|
|
|
|
"created_at": n.created_at.isoformat() if n.created_at else None,
|
|
|
|
|
}
|