""" In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) + e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033) + push til telefonens OS (2026-07-28, migrasjon 037, se app/push.py). Push har -- ulikt e-post -- INGEN egen per-type opt-in: selve det å abonnere (gi nettleser-tillatelse) ER samtykket, en abonnert enhet får push for ALLE typer create_notification() skriver. 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 PÅ 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 from ..errors import app_error from ..push import send_push_to_user router = APIRouter(tags=["notifications"]) NotificationType = Literal["friend", "tournament", "round", "result"] _NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("friend", "tournament", "round", "result") _PUSH_TITLES: dict[str, str] = { "friend": "Venner", "round": "Runde", "result": "Resultat", "tournament": "Turnering", } 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, OG et push-varsel til enhver enhet mottakeren har abonnert (uavhengig av e-post-valget -- se _push_for_notification).""" await conn.execute( "INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)", user_id, type, message, link_path, ) await _push_for_notification(conn, user_id=user_id, type=type, message=message, link_path=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) async def _push_for_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None: """Kalt fra create_notification() -- egen liten funksjon KUN for at push (uavhengig av e-post-preferansen over) alltid forsøkes for enhver abonnert enhet. Feiler aldri synlig for kalleren, se app/push.py.""" try: await send_push_to_user(conn, user_id, _PUSH_TITLES.get(type, "TeeCup"), message, link_path) except Exception: traceback.print_exc() 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} # --------------------------------------------------------------------------- # Push-varsler til telefonens OS (Web Push/VAPID, migrasjon 037). Offentlig # nøkkel er nettopp det -- offentlig, ingen auth nødvendig for GET-en (samme # begrunnelse som ethvert annet "her er min offentlige nøkkel"-endepunkt). # Abonner/avbryt krever derimot en ekte sesjon -- abonnementet er alltid # knyttet til AKKURAT den innloggede brukeren. # --------------------------------------------------------------------------- class VapidPublicKeyOut(BaseModel): public_key: str | None configured: bool @router.get("/push/vapid-public-key", response_model=VapidPublicKeyOut) async def get_vapid_public_key() -> VapidPublicKeyOut: return VapidPublicKeyOut(public_key=settings.VAPID_PUBLIC_KEY, configured=settings.PUSH_CONFIGURED) class PushSubscriptionKeys(BaseModel): p256dh: str auth: str class PushSubscriptionCreate(BaseModel): endpoint: str keys: PushSubscriptionKeys @router.post("/push/subscribe") async def subscribe_push( body: PushSubscriptionCreate, user: CurrentUser = Depends(get_current_user) ) -> dict[str, bool]: if not settings.PUSH_CONFIGURED: raise app_error(400, "VALIDATION_FAILED", "Push er ikke konfigurert på serveren.") async with plain_connection() as conn: # Samme endpoint kan i prinsippet re-abonneres (f.eks. nøklene # rotert av nettleseren) -- oppdater i stedet for å feile på # unik-constrainten. await conn.execute( """ INSERT INTO push_subscription (user_id, endpoint, p256dh, auth) VALUES ($1, $2, $3, $4) ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, p256dh = $3, auth = $4 """, user.user_id, body.endpoint, body.keys.p256dh, body.keys.auth, ) return {"ok": True} class PushUnsubscribe(BaseModel): endpoint: str @router.delete("/push/subscribe") async def unsubscribe_push( body: PushUnsubscribe, user: CurrentUser = Depends(get_current_user) ) -> dict[str, bool]: async with plain_connection() as conn: await conn.execute( "DELETE FROM push_subscription WHERE endpoint = $1 AND user_id = $2", body.endpoint, user.user_id, ) return {"ok": True}