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:
parent
7631ef60e6
commit
4c0fd666a4
2 changed files with 151 additions and 4 deletions
58
CLAUDE.md
58
CLAUDE.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
nå (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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue