Update Todos

Les add_participant, complete_round, friend_categorization-skjema for design

Design + skriv backend for tre rundevarsler (medspiller lagt til, venn starter synlig runde, tilkoblet runde fullført)

Scratch-verifiser alle tre varseltypene

Oppdater CLAUDE.md status + rull ut mot ekte systemer (med bekreftelse)
Live. Rundevarsler er nå koblet på, ren backend-endring (varslingssenteret hadde allerede round/result-typene klare, bare ingenting skrev dem):

Medspiller lagt til — varsel med lenke til /my-rounds/{id} (full tilgang)
En venn starter en synlig runde — varsel til aksepterte venner som faktisk kan se den (offentlig, eller riktig kategori), lenke til /watch/{id}
Tilkoblet runde fullført — medspillere og tredjeparts-venner varsles, aldri personen som selv fullførte
28/28 nye sjekker + full regresjon av tre eksisterende testsuiter (191 sjekker) grønt. Ingen migrasjon, kun teecup_api redeployet, teeoff.no upåvirket.
This commit is contained in:
Erol Haagenrud 2026-07-28 19:54:05 +02:00
parent 7631ef60e6
commit 4c0fd666a4
2 changed files with 151 additions and 4 deletions

View file

@ -4615,6 +4615,58 @@ Ferdig og verifisert:
**ADR-036 er dermed HELT ferdig, alle tre faser** (venner-kjernen,
rundevisibilitet, ekte medspillere) — backend + frontend, live.
- **Rundevarsler koblet til det eksisterende varslingssenteret, BYGGET,
SCRATCH-VERIFISERT OG LIVE (2026-07-28), samme dag:** direkte oppfølging
av ADR-036 fase 2 — varslingssenteret (2026-07-26) hadde fra start
reservert `type` for `"round"`/`"result"` (skjema OG frontendens
`KIND_ICON`/`KIND_LABEL`), men INGENTING skrev noensinne en slik rad —
kun venneforespørsler trigget et varsel. Bruker bekreftet omfang
eksplisitt (AskUserQuestion, alle tre valgt): (A) lagt til som ekte
medspiller på en runde, (B) en venn starter en runde du kan se
(offentlig, ELLER `friends`-synlig med treffende kategori), (C) en
runde du er koblet til (eier/medspiller/tredjeparts-venn fra B) blir
fullført.
**Ren backend-endring, INGEN frontend-endring nødvendig** — bekreftet
ved lesing av `notifications.tsx` FØR noe ble bygget: `NotificationKind`/
`KIND_ICON`/`KIND_LABEL` dekker allerede `"round"`/`"result"` fullt ut,
siden de ble reservert med akkurat dette for øye i den opprinnelige
runden.
**Bygget i `app/routers/rounds.py`:** ny delt `_friends_who_can_see_
round(conn, round_id, owner_user_id)` — GJENBEREGNER (ikke en lagret
mottakerliste) nøyaktig samme regel som `_can_view_round` (offentlig =
alle aksepterte venner, `friends` = kun de hvis EGEN kategorisering av
venn treffer rundens synlige kategorier), brukt BÅDE ved opprettelse og
ved fullføring — aldri ute av synk med selve tilgangskontrollen.
`create_round` sender (B) rett etter transaksjonen (kun for
`public`/`friends`, aldri `private`), lenke til `/watch/{id}`.
`add_participant` sender (A) inni transaksjonen når `user_id` er satt
(aldri for gjester — de har ingen konto å varsle), lenke til
`/my-rounds/{id}` (full tilgang, ikke tredjeparts-visningen).
`complete_round` sender (C) til to ATSKILTE mottakergrupper med ulik
lenke, siden de har ulik tilgang: lenkede medspillere (unntatt den som
selv fullførte) → `/my-rounds/{id}`; tredjeparts-venner fra (B),
gjenberegnet på nytt → `/watch/{id}` — ingen dobbel-varsling hvis noen
skulle være i begge grupper (settoperasjon), og aldri et varsel til
personen som selv utførte handlingen.
**Scratch-verifisert grundig, 28/28 nye sjekker** (isolert
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
container, samme mønster som hele prosjektet, alle 32 migrasjoner kjørt
friskt): venn fikk `round`-varsel ved synlig opprettelse, fremmed fikk
det IKKE, privat runde ga INGEN varsel til noen, medspiller fikk
`round`-varsel med riktig `/my-rounds/`-lenke ved tilføyelse, fullføring
ga `result`-varsel til BÅDE medspiller (`/my-rounds/`) og venn
(`/watch/`) men ALDRI til den som selv fullførte, en privat solorunde
sin fullføring ga fortsatt ingen varsler, mark-as-read uendret. PLUSS
full regresjon av tre eksisterende testsuiter (co-player-flyt 35/35,
spillformat-flyt 122/122, ADR-036 fase 2-synlighetsmatrise 34/34) — alle
fortsatt 100 % grønne, ingen utilsiktet bivirkning av de nye
`create_notification()`-kallene inni eksisterende transaksjoner.
`test_isolation.sql` 12/12 uendret (ingen migrasjon, ren Python-logikk).
**Rullet ut live 2026-07-28**, ingen migrasjon, kun
`docker compose up -d --build teecup_api`. Ren boot
(`Application startup complete`), `/health`/`/dashboard` → 200,
`teeoff.no` upåvirket.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
@ -4828,8 +4880,10 @@ Neste steg:
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
faser); (d) flere flighter i én frittstående runde
fortsatt kun drøftet (se
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
til rundehendelser ennå.
FEATURE_BACKLOG.md); (e) ~~varsler koblet til venneforespørsler, ikke
til rundehendelser ennå~~ — ferdig, scratch-verifisert og live
2026-07-28, se status over (medspiller lagt til/venn ser synlig
runde/tilkoblet runde fullført).
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
foursome/greensome/scramble) for frittstående runder — backend
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,

View file

@ -45,6 +45,7 @@ from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
from ..realtime import broadcast_round_update, live_sockets_for_round
from .notifications import create_notification
from handicap_engine import (
HoleResult,
adjusted_gross_score,
@ -950,6 +951,23 @@ async def create_round(
category,
)
# Rundevarsler (2026-07-28 oppfølging av ADR-036 fase 2) -- kun
# meningsfullt for 'public'/'friends' ('private' gir alltid en tom
# liste). Sendt EN GANG, her, ikke gjenberegnet ved hver visning.
if body.visibility_mode != "private":
watcher_ids = await _friends_who_can_see_round(conn, round_id, user.user_id)
if watcher_ids:
owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
round_label = (body.name.strip() if body.name and body.name.strip() else None) or resolved.course_name
for friend_id in watcher_ids:
await create_notification(
conn,
user_id=friend_id,
type="round",
message=f"{owner_name} startet en runde du kan følge ({round_label}).",
link_path=f"/watch/{round_id}",
)
return await _load_round_out(conn, round_id, user.user_id)
@ -1353,6 +1371,42 @@ async def _get_viewable_round_or_404(conn, round_id: str, viewer_user_id: str |
raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.")
async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) -> list[str]:
"""Rundevarsler (FEATURE_BACKLOG.md, 2026-07-28 oppfølging av ADR-036
fase 2) -- aksepterte venner av EIEREN som kan se DENNE runden akkurat
(offentlig, eller riktig venne-kategori), brukt til fan-out-varsling
ved opprettelse OG ved fullføring. Bevisst GJENBEREGNET hver gang (ikke
en lagret mottakerliste) -- samme regel som selve tilgangskontrollen i
`_can_view_round`, aldri ute av synk med den."""
row = await conn.fetchrow("SELECT visibility_mode FROM round WHERE id = $1", round_id)
if row is None or row["visibility_mode"] == "private":
return []
friend_rows = await conn.fetch(
"""
SELECT (CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END)::text AS friend_id
FROM friendship
WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
""",
owner_user_id,
)
friend_ids = [r["friend_id"] for r in friend_rows]
if not friend_ids:
return []
if row["visibility_mode"] == "public":
return friend_ids
matched = await conn.fetch(
"""
SELECT DISTINCT fc.friend_user_id::text AS friend_id
FROM friend_categorization fc
JOIN round_visible_category rvc ON rvc.category = fc.category
WHERE fc.owner_user_id = $1 AND rvc.round_id = $2
""",
owner_user_id,
round_id,
)
return [r["friend_id"] for r in matched]
# ---------------------------------------------------------------------------
# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder --
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en
@ -1680,7 +1734,8 @@ async def add_participant(
async with plain_connection() as conn:
round_row = await conn.fetchrow(
"SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, "
"teeoff_course_id, personal_course_id, tee_name_snapshot, play_format, completed_at "
"teeoff_course_id, personal_course_id, tee_name_snapshot, play_format, completed_at, "
"name, course_name_snapshot "
"FROM round WHERE id = $1",
round_id,
)
@ -1747,6 +1802,17 @@ 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}",
)
row = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
@ -2834,7 +2900,11 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
# Samme begrunnelse som update_hole -- en medspiller som fører
# score for flighten skal også kunne markere runden ferdig.
await _get_accessible_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT holes_planned, play_format FROM round WHERE id = $1", round_id)
round_row = await conn.fetchrow(
"SELECT holes_planned, play_format, owner_user_id::text AS owner_user_id, name, course_name_snapshot "
"FROM round WHERE id = $1",
round_id,
)
# 2026-07-28 oppfølging: match/skins/fourball/foursome/greensome/
# scramble krever flere spillere enn slagspill -- ingenting fanget
@ -2891,6 +2961,29 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
if p["user_id"] is not None:
await _recompute_computed_handicap_index(conn, p["user_id"])
# Rundevarsler (2026-07-28 oppfølging av ADR-036 fase 2) -- to
# atskilte mottakergrupper med ulik lenke, siden de har ulik
# tilgang: lenkede medspillere ser den fulle autentiserte siden,
# tredjeparts-venner kun tredjeparts-/spectator-visningen.
actor_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"]
message = f"{actor_name} fullførte runden ({round_label})."
linked_ids = {p["user_id"] for p in participants if p["user_id"] is not None}
linked_ids.discard(user.user_id)
for uid in linked_ids:
await create_notification(
conn, user_id=uid, type="result", message=message, link_path=f"/my-rounds/{round_id}",
)
watcher_ids = set(await _friends_who_can_see_round(conn, round_id, round_row["owner_user_id"]))
watcher_ids -= linked_ids
watcher_ids.discard(user.user_id)
for uid in watcher_ids:
await create_notification(
conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}",
)
result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id)