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,
`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

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"
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)

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

View file

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

View file

@ -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() {
<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">
<div className="flex items-center gap-2.5">
<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>
)
}
// --- 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>
)
}