diff --git a/033_notification_email_prefs.sql b/033_notification_email_prefs.sql new file mode 100644 index 0000000..cdc8c42 --- /dev/null +++ b/033_notification_email_prefs.sql @@ -0,0 +1,21 @@ +-- ===================================================================== +-- TeeCup — e-post-fallback for varsler, per type (migrasjon 033, +-- 2026-07-28 oppfølging av varslingssenteret) +-- ===================================================================== +-- Trygg standard: en bruker som ikke har gjort noe valg får ALDRI e-post +-- (samme "se ingenting"-filosofi som resten av appen, ADR-018/036) -- +-- raden i user_notification_email_pref må EKSPLISITT finnes for at +-- create_notification() (app/routers/notifications.py) skal sende e-post +-- for akkurat den typen. Samme "tilstedeværelse = valgt"-mønster som +-- round_visible_category (032) og friend_categorization (025) -- ingen +-- egen boolean-kolonne per type, en rad per (bruker, type) i stedet. +\set ON_ERROR_STOP on + +CREATE TABLE user_notification_email_pref ( + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + -- Samme faste sett som notification.type (026_notifications.sql). + type text NOT NULL CHECK (type IN ('friend', 'tournament', 'round', 'result')), + PRIMARY KEY (user_id, type) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON user_notification_email_pref TO teecup_app; diff --git a/CLAUDE.md b/CLAUDE.md index 1936cc9..905e50c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4667,6 +4667,65 @@ Ferdig og verifisert: (`Application startup complete`), `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. +- **E-post-fallback for varsler, per type, BYGGET OG SCRATCH-VERIFISERT + (2026-07-28), samme dag, rett etter rundevarsel-runden:** brukeren ba + eksplisitt om at mottakeren selv skal kunne velge HVILKE varseltyper som + skal utløse e-post — ikke en enkelt global av/på-bryter. Ny migrasjon + `033_notification_email_prefs.sql`: `user_notification_email_pref` + (`user_id`, `type`, samme CHECK-sett som `notification.type`) — samme + "tilstedeværelse = valgt"-mønster som `round_visible_category`/ + `friend_categorization`, TRYGG STANDARD ingen rad = ingen e-post for + noen type (samme "se ingenting til noen har valgt"-filosofi som resten + av appen). + **Bevisst ÉN felles innsnevring, ikke ett kallsted per varseltrigger:** + e-post-utsendingen ligger INNI `create_notification()` selv (etter selve + INSERT-en) — modul-docstringen kalte den allerede "den eneste + skrivevegen inn", så alle nåværende OG fremtidige varsel-triggere (i dag + 2 i friends.py, 4 i rounds.py) får e-post-støtte helt uten å røres, + ingen risiko for at et fremtidig kallsted glemmer det. Samme + `SMTP_CONFIGURED`-sjekk + try/except + `traceback.print_exc()`-mønster + som all annen e-postutsending i appen (routers/auth.py, + organizations.py) — en driftsfeil i selve SMTP-en skal ALDRI hindre at + in-app-varselet (allerede skrevet FØR e-post-forsøket) består. + Ny `send_notification_email()` i `app/email.py` — bevisst tospråklig KUN + i ramme-teksten (emne/hilsen/lenkeforklaring); selve `message`-teksten + er allerede en ferdig norsk snapshot-tekst (samme prinsipp som + in-app-varselet), ingen full i18n av selve varselinnholdet i denne + runden. + Nye `GET`/`PUT /notifications/email-prefs` — PUT er en FULL erstatning + (samme kontrakt som `PUT /friends/{id}/categories`), ikke en delvis + PATCH. + **Liten, men reell ryddejobb funnet FØR e-post-laget ble lagt på:** + forrige rundes `add_participant`-varsel (medspiller lagt til) lå INNI + den åpne DB-transaksjonen for selve deltaker-innsettingen — flyttet til + RETT ETTER (samme mønster som `create_round`/`complete_round` allerede + fulgte), slik at en fremtidig blokkerende SMTP-utsending aldri skjer + mens en transaksjon holder låser. + **Frontend:** ny seksjon "Varsler på e-post" i `/account` + (`NotificationEmailPrefsSection`, `account-settings.tsx`) — fire + avkrysningsbokser (Venneforespørsler/Runder/Resultater/Turneringer, sistnevnte + reservert for fremtidig bruk), samme avkrysningsboks-mønster som + kølle-bag-listen lenger opp i samme fil, lagrer umiddelbart ved hvert + klikk (ingen egen "lagre"-knapp). + **Scratch-verifisert grundig, 19/19 nye sjekker** (isolert + `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API- + container, alle 33 migrasjoner kjørt friskt): trygg standard bekreftet + (ingen typer valgt fra start), full-erstatning bekreftet (PUT med ett + sett fjerner det forrige, legger ikke til), e-post-fallback FAKTISK + logget (dev-log-varianten av `SMTP_CONFIGURED`-grenen) for en bruker med + `round`/`result` valgt inn — BÅDE ved medspiller-tilføyelse og ved + fullføring — INGEN e-post til brukeren som selv utførte handlingen, + INGEN e-post til en bruker (eieren) som ikke har valgt inn NOE, og etter + å ha slått AV `result` igjen: ingen ny e-post ved neste fullføring MENS + in-app-varselet fortsatt opprettes helt uendret (de to er reelt + atskilte, ikke koblet). PLUSS full regresjon av fire eksisterende + testsuiter (rundevarsler 28/28, co-player-flyt 35/35, spillformat-flyt + 122/122, ADR-036 fase 2-synlighetsmatrise 34/34) — alle fortsatt 100 % + grønne, ingen bivirkning av at `add_participant`s varsel flyttet utenfor + transaksjonen. `test_isolation.sql` 12/12 uendret. Ekte typesjekket + produksjonsbuild (samme `Dockerfile` som deployes) kompilerte rent, + alle 24 ruter listet uendret. + Neste steg: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen diff --git a/app/email.py b/app/email.py index d87e089..e53ac4e 100644 --- a/app/email.py +++ b/app/email.py @@ -222,3 +222,44 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}&kind=secondary" body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link) await to_thread(_send_sync, to_email, template["subject"], body) + + +_NOTIFICATION_TEMPLATES = { + "nb": { + "subject": "Nytt varsel fra TeeCup", + "body": ( + "Hei,\n\n" + "{message}\n\n" + "Se det i TeeCup:\n{link}\n\n" + "Du får denne e-posten fordi du har valgt å motta e-post for " + "denne typen varsler. Du kan endre dette når som helst under " + "Konto i TeeCup.\n" + ), + }, + "en": { + "subject": "New notification from TeeCup", + "body": ( + "Hi,\n\n" + "{message}\n\n" + "View it in TeeCup:\n{link}\n\n" + "You're receiving this because you opted in to email for this " + "notification type. You can change this anytime under Account " + "in TeeCup.\n" + ), + }, +} + + +async def send_notification_email(to_email: str, message: str, link_path: str, locale: str = "nb") -> None: + """E-post-fallback for det in-app varslingssenteret (FEATURE_BACKLOG.md, + 2026-07-28 oppfølging) -- kun sendt når mottakeren selv har valgt inn + for akkurat DENNE varseltypen (`user_notification_email_pref`, + migrasjon 033), se `create_notification()` i + `app/routers/notifications.py`. `message` er allerede en ferdig, norsk + tekst (samme snapshot-prinsipp som selve in-app-varselet) -- kun + ramme-teksten (emne/hilsen/lenkeforklaring) er tospråklig her, ikke + selve budskapet.""" + template = _NOTIFICATION_TEMPLATES.get(locale, _NOTIFICATION_TEMPLATES["nb"]) + link = f"{settings.PUBLIC_BASE_URL}{link_path}" + body = template["body"].format(message=message, link=link) + await to_thread(_send_sync, to_email, template["subject"], body) diff --git a/app/routers/notifications.py b/app/routers/notifications.py index cb89b18..033ef5d 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -1,30 +1,47 @@ """ -In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25). +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 (i dag kun -friends.py) ved konkrete hendelser, ikke noe generisk event-system. +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 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.""" + 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, @@ -33,6 +50,65 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes 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 diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 912c2af..016c737 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -1803,16 +1803,20 @@ async def add_participant( if body.round_side_id is not None: await _recompute_side_handicaps(conn, round_id, round_row["play_format"]) - if linked_user_id is not None: - owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) - round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"] - await create_notification( - conn, - user_id=linked_user_id, - type="round", - message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).", - link_path=f"/my-rounds/{round_id}", - ) + # Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er + # begått, samme mønster som create_round/complete_round (en + # e-post-utsending, se create_notification, skal aldri skje mens + # en DB-transaksjon holder låser). + if linked_user_id is not None: + owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) + round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"] + await create_notification( + conn, + user_id=linked_user_id, + type="round", + message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).", + link_path=f"/my-rounds/{round_id}", + ) row = await conn.fetchrow( """ SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email, diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index ede3976..8fbcbd3 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -11,6 +11,7 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { ArrowLeft, + Bell, Camera, KeyRound, Lock, @@ -277,6 +278,8 @@ export function AccountSettings() { + +
@@ -1315,3 +1318,101 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
) } + +// --- E-post-fallback for varsler (2026-07-28 oppfølging) -------------------- +// Trygg standard: ingen typer valgt inntil brukeren selv velger inn -- +// PUT /notifications/email-prefs er en FULL erstatning (samme mønster som +// venne-kategoriseringen), ikke en delvis PATCH. +const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [ + { value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." }, + { value: "round", label: "Runder", description: "Du blir lagt til som medspiller, eller en venn starter en runde du kan følge." }, + { value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." }, + { value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." }, +] + +function NotificationEmailPrefsSection() { + const [types, setTypes] = useState([]) + const [loaded, setLoaded] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + useEffect(() => { + fetch("/notifications/email-prefs", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { types: string[] } | null) => { + if (data) setTypes(data.types) + setLoaded(true) + }) + .catch(() => setLoaded(true)) + }, []) + + async function save(next: string[]) { + setTypes(next) + setSaving(true) + setError(null) + setSuccess(false) + try { + const res = await fetch("/notifications/email-prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ types: next }), + }) + if (!res.ok) throw new Error("Klarte ikke å lagre varselinnstillingene.") + setSuccess(true) + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + } finally { + setSaving(false) + } + } + + return ( +
+
+
+
+

Varsler på e-post

+
+

+ Du får alltid varsler i appen (klokke-ikonet). Velg her hvilke typer du i tillegg vil ha på + e-post -- ingen sendes med mindre du krysser av. +

+ + {loaded && ( +
+ {NOTIFICATION_TYPE_OPTIONS.map((opt) => { + const checked = types.includes(opt.value) + return ( + + ) + })} +
+ )} + + {error &&

{error}

} + {success &&

Lagret.

} +
+ ) +}