Update Todos

Skriv migrasjon 033 (e-post-preferanser per varseltype)

Legg til send_notification_email() i app/email.py

Bygg GET/PUT /notifications/email-prefs + koble e-post inn i create_notification()

Bygg frontend: velg varseltyper for e-post i /account

Scratch-verifiser hele løpet (preferanser + faktisk e-postutsending)

Oppdater CLAUDE.md + rull ut mot ekte systemer (med bekreftelse)
Live. E-post-fallback for varsler er ferdig, per-type valgbar av mottakeren selv:

Ny seksjon "Varsler på e-post" i /account — fire avkrysningsbokser (Venneforespørsler/Runder/Resultater/Turneringer), trygg standard er ingen valgt (ingen e-post inntil du eksplisitt krysser av).
E-post-utsendingen ligger ett sted (inni create_notification()), så alle nåværende og fremtidige varseltyper får e-post-støtte automatisk.
19/19 nye sjekker + full regresjon av fire eksisterende testsuiter (219 sjekker totalt) grønt.
Migrasjon 033 kjørt mot ekte database (kun ny tabell), test_isolation.sql fortsatt 12/12, begge containere redeployet, teeoff.no upåvirket.
This commit is contained in:
Erol Haagenrud 2026-07-28 20:09:21 +02:00
parent 4c0fd666a4
commit 58a1242a9b
6 changed files with 316 additions and 14 deletions

View file

@ -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;

View file

@ -4667,6 +4667,65 @@ Ferdig og verifisert:
(`Application startup complete`), `/health`/`/dashboard` → 200, (`Application startup complete`), `/health`/`/dashboard` → 200,
`teeoff.no` upåvirket. `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: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen

View file

@ -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" 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) body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
await to_thread(_send_sync, to_email, template["subject"], body) 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)

View file

@ -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 Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
mønster som personlig profil/HCP-historikk/venner. `create_notification()` mønster som personlig profil/HCP-historikk/venner. `create_notification()`
er den eneste skrivevegen inn -- kalt fra andre routere (i dag kun er den eneste skrivevegen inn -- kalt fra andre routere (friends.py,
friends.py) ved konkrete hendelser, ikke noe generisk event-system. 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 from __future__ import annotations
import traceback
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from ..auth import CurrentUser, get_current_user from ..auth import CurrentUser, get_current_user
from ..config import settings
from ..db import plain_connection from ..db import plain_connection
from ..email import send_notification_email
router = APIRouter(tags=["notifications"]) router = APIRouter(tags=["notifications"])
NotificationType = Literal["friend", "tournament", "round", "result"] 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: 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 -- """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( await conn.execute(
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)", "INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
user_id, user_id,
@ -33,6 +50,65 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
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)
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): class NotificationOut(BaseModel):
id: str id: str

View file

@ -1803,6 +1803,10 @@ async def add_participant(
if body.round_side_id is not None: if body.round_side_id is not None:
await _recompute_side_handicaps(conn, round_id, round_row["play_format"]) await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
# 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: if linked_user_id is not None:
owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) 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"] round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]

View file

@ -11,6 +11,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { import {
ArrowLeft, ArrowLeft,
Bell,
Camera, Camera,
KeyRound, KeyRound,
Lock, Lock,
@ -277,6 +278,8 @@ export function AccountSettings() {
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} /> <PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
<NotificationEmailPrefsSection />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
@ -1315,3 +1318,101 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
</section> </section>
) )
} }
// --- 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<string[]>([])
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(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 (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Bell aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Varsler e-post</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Du får alltid varsler i appen (klokke-ikonet). Velg her hvilke typer du i tillegg vil ha
e-post -- ingen sendes med mindre du krysser av.
</p>
{loaded && (
<div className="flex flex-col gap-1.5">
{NOTIFICATION_TYPE_OPTIONS.map((opt) => {
const checked = types.includes(opt.value)
return (
<label
key={opt.value}
className={`flex min-h-11 items-start gap-3 rounded-xl border px-3 py-2.5 ${
checked ? "border-primary bg-primary/10" : "border-border"
} ${saving ? "opacity-70" : "cursor-pointer"}`}
>
<input
type="checkbox"
checked={checked}
disabled={saving}
onChange={(e) =>
void save(e.target.checked ? [...types, opt.value] : types.filter((t) => t !== opt.value))
}
className="mt-0.5 size-4 shrink-0 rounded border-border"
/>
<span className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{opt.label}</span>
<span className="text-xs text-muted-foreground text-pretty">{opt.description}</span>
</span>
</label>
)
})}
</div>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Lagret.</p>}
</section>
)
}