teecup/app/routers/notifications.py

176 lines
6.5 KiB
Python
Raw Normal View History

"""
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) +
e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033).
Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
mønster som personlig profil/HCP-historikk/venner. `create_notification()`
er den eneste skrivevegen inn -- kalt fra andre routere (friends.py,
rounds.py) ved konkrete hendelser, ikke noe generisk event-system.
Trygg standard (samme filosofi som visibility_mode='private' ellers i
appen): en bruker som ikke har gjort noe valg får ALDRI e-post for noen
type -- kun `user_notification_email_pref`-raden EN BRUKER selv setter
(via PUT /notifications/email-prefs) slår e-post for akkurat den typen.
E-post-utsendingen skjer INNI `create_notification()` selv (ikke i hvert
enkelt kallsted) -- driftsfeil i utsendingen skal ALDRI hindre selve
in-app-varselet fra å bli opprettet, samme mønster (SMTP_CONFIGURED-sjekk
+ try/except + traceback.print_exc()) som all annen e-post i appen
(app/routers/auth.py, organizations.py).
"""
from __future__ import annotations
import traceback
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from ..auth import CurrentUser, get_current_user
from ..config import settings
from ..db import plain_connection
from ..email import send_notification_email
router = APIRouter(tags=["notifications"])
NotificationType = Literal["friend", "tournament", "round", "result"]
_NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("friend", "tournament", "round", "result")
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en
e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for
akkurat DENNE typen."""
await conn.execute(
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
user_id,
type,
message,
link_path,
)
wants_email = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
user_id,
type,
)
if not wants_email:
return
recipient = await conn.fetchrow(
"SELECT email, preferred_locale FROM app_user WHERE id = $1 AND email IS NOT NULL",
user_id,
)
if recipient is None:
return
if settings.SMTP_CONFIGURED:
try:
await send_notification_email(recipient["email"], message, link_path, recipient["preferred_locale"])
except Exception:
# Se modul-docstring: driftsfeil i utsendingen skal aldri
# hindre selve in-app-varselet, som allerede er skrevet over.
traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Varsel-e-post til {recipient['email']} ({type}): {message}", flush=True)
class NotificationEmailPrefsOut(BaseModel):
types: list[str]
class NotificationEmailPrefsUpdate(BaseModel):
types: list[NotificationType]
@router.get("/notifications/email-prefs", response_model=NotificationEmailPrefsOut)
async def get_email_prefs(user: CurrentUser = Depends(get_current_user)) -> NotificationEmailPrefsOut:
async with plain_connection() as conn:
rows = await conn.fetch(
"SELECT type FROM user_notification_email_pref WHERE user_id = $1",
user.user_id,
)
return NotificationEmailPrefsOut(types=[r["type"] for r in rows])
@router.put("/notifications/email-prefs", response_model=NotificationEmailPrefsOut)
async def set_email_prefs(
body: NotificationEmailPrefsUpdate, user: CurrentUser = Depends(get_current_user)
) -> NotificationEmailPrefsOut:
"""Full-erstatning, samme mønster som PUT /friends/{id}/categories --
sender med en tom liste slår e-post AV for alle typer igjen."""
types = sorted(set(body.types))
async with plain_connection() as conn, conn.transaction():
await conn.execute("DELETE FROM user_notification_email_pref WHERE user_id = $1", user.user_id)
for t in types:
await conn.execute(
"INSERT INTO user_notification_email_pref (user_id, type) VALUES ($1, $2)",
user.user_id,
t,
)
return NotificationEmailPrefsOut(types=types)
class NotificationOut(BaseModel):
id: str
type: str
message: str
link_path: str
created_at: str
read_at: str | None
@router.get("/notifications", response_model=list[NotificationOut])
async def list_notifications(user: CurrentUser = Depends(get_current_user)) -> list[NotificationOut]:
async with plain_connection() as conn:
rows = await conn.fetch(
"""
SELECT id::text AS id, type, message, link_path, created_at, read_at
FROM notification WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 100
""",
user.user_id,
)
return [
NotificationOut(
id=r["id"],
type=r["type"],
message=r["message"],
link_path=r["link_path"],
created_at=r["created_at"].isoformat(),
read_at=r["read_at"].isoformat() if r["read_at"] else None,
)
for r in rows
]
@router.get("/notifications/unread-count")
async def unread_count(user: CurrentUser = Depends(get_current_user)) -> dict[str, int]:
async with plain_connection() as conn:
count = await conn.fetchval(
"SELECT COUNT(*) FROM notification WHERE user_id = $1 AND read_at IS NULL",
user.user_id,
)
return {"count": count}
@router.post("/notifications/{notification_id}/read")
async def mark_read(notification_id: str, user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]:
async with plain_connection() as conn:
await conn.execute(
"UPDATE notification SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL",
notification_id,
user.user_id,
)
return {"ok": True}
@router.post("/notifications/read-all")
async def mark_all_read(user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]:
async with plain_connection() as conn:
await conn.execute(
"UPDATE notification SET read_at = now() WHERE user_id = $1 AND read_at IS NULL",
user.user_id,
)
return {"ok": True}