From 4c0fd666a45c4e79e52e67e27c093c4b598648e2 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 28 Jul 2026 19:54:05 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 58 +++++++++++++++++++++++++- app/routers/rounds.py | 97 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f06b103..1936cc9 100644 --- a/CLAUDE.md +++ b/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, diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 06c0521..912c2af 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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)