diff --git a/066_round_participant_completion.sql b/066_round_participant_completion.sql new file mode 100644 index 0000000..9cf64a6 --- /dev/null +++ b/066_round_participant_completion.sql @@ -0,0 +1,24 @@ +-- ===================================================================== +-- TeeCup — migrasjon 066 +-- Per-deltaker avslutning av frittstående runder +-- ===================================================================== +-- round.completed_at er rundeomfattende -- hele runden må fullføres +-- samtidig for ALLE deltakere, noe som ikke holder når spillere i +-- samme flight går ulikt antall hull (f.eks. to spiller 18, én 9, én +-- må avslutte etter 14). Denne kolonnen lar EN deltaker markeres +-- ferdig uavhengig av resten av flighten -- stopper "mangler +-- score"-nagging for DEM, uten å låse scoreføring for de som +-- fortsatt spiller (som `round.completed_at` ville gjort). +-- +-- Rundens EGEN fullføring (`round.completed_at`, HCP-beregning, +-- varsler) forblir bevisst en separat, manuell handling (bekreftet +-- med bruker) -- denne kolonnen er KUN en nagging-brems + +-- e-post-trigger, ingen auto-cascade. +-- +-- Ingen CHECK mot round.completed_at -- applikasjonslaget garanterer +-- rekkefølgen (app/routers/rounds.py). +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE round_participant ADD COLUMN completed_at timestamptz; diff --git a/067_golfapi_coordinate_more_poi_types.sql b/067_golfapi_coordinate_more_poi_types.sql new file mode 100644 index 0000000..87dfddd --- /dev/null +++ b/067_golfapi_coordinate_more_poi_types.sql @@ -0,0 +1,26 @@ +-- ===================================================================== +-- TeeCup — migrasjon 067 +-- Flere POI-typer for manuelt registrerte banepunkter +-- ===================================================================== +-- GolfAPIs 12 faste POI-koder dekker ikke alt en bane faktisk kan ha -- +-- konkret anledning: Nesbyen (Nesfjellet Golfklubb) manglet GPS-data +-- hos GolfAPI (hasGPS=0), og brukeren registrerte 124 punkter manuelt +-- fra egen bane-befaring. To av navnene der ("fjellknaus"/rock outcrop, +-- "til fairway"/siktepunkt for utslaget) har ingen match i dagens sett. +-- +-- `rock` telles som en HINDRING i avstandsvisningen (samme klasse som +-- bunker/vann -- noe å styre unna), `layup` er rent informativt (et +-- siktepunkt, ikke noe å unngå) -- se _HAZARD_LABELS i +-- frontend/components/hole-target-distance.tsx. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE golfapi_course_coordinate DROP CONSTRAINT golfapi_course_coordinate_poi_type_check; +ALTER TABLE golfapi_course_coordinate ADD CONSTRAINT golfapi_course_coordinate_poi_type_check + CHECK (poi_type IN ( + 'green', 'green_bunker', 'fairway_bunker', 'water', + 'trees', 'marker_100', 'marker_150', 'marker_200', + 'dogleg', 'road', 'tee_front', 'tee_back', + 'rock', 'layup' + )); diff --git a/068_golfapi_tee_men_rating_nullable.sql b/068_golfapi_tee_men_rating_nullable.sql new file mode 100644 index 0000000..f625ecd --- /dev/null +++ b/068_golfapi_tee_men_rating_nullable.sql @@ -0,0 +1,18 @@ +-- ===================================================================== +-- TeeCup — migrasjon 068 +-- golfapi_course_tee: menns rating/slope kan mangle +-- ===================================================================== +-- Reell anledning (Nesfjellet Golfklubb/Nesbyen, 2026-08-13): migrasjon +-- 065 antok (som ADR-019s teeoff-import) at menns rating alltid finnes +-- hos kilden -- kvinners er valgfritt. GolfAPI viste seg å returnere en +-- TOM STRENG for `slopeMen` på minst ett utslag her, ikke bare for +-- kvinner. `golfapi_course_tee` sin cache må derfor tåle at BEGGE +-- kjønn kan mangle -- selve import/kopier-inn-stegene (courses.py/ +-- rounds.py) avviser fortsatt (EXTERNAL_DATA_INCOMPLETE) dersom et +-- utslag ender opp UTEN noen brukbar rating i det hele tatt. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE golfapi_course_tee ALTER COLUMN course_rating_men DROP NOT NULL; +ALTER TABLE golfapi_course_tee ALTER COLUMN slope_men DROP NOT NULL; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 20e7d37..a57b1e7 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -6215,6 +6215,50 @@ runde"-veiviseren, ingen konsollfeil, søket bekreftet returnere reelle treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg) for full detalj. +**Tillegg 2026-08-13 — to nye baner (Larvik/Seasidebanen, Nesbyen/ +Nesfjellet) + en reell GolfAPI-datakvalitetsbug funnet under importen.** +Nesbyen har `hasGPS=0` hos GolfAPI (ingen koordinater), så koordinatene +ble registrert MANUELT av bruker (124 punkter, feltbefart) og satt inn +direkte i den samme delte `golfapi_course_coordinate`-cachen som +Beslutning C beskriver — skjemaet er kilde-agnostisk, rangefinderen +fungerer identisk uansett om et punkt kom fra API-et eller ble hånd- +registrert. To nye `poi_type`-verdier lagt til (migrasjon 067): `rock` +(fjellknaus — regnes som hinder) og `layup` (til fairway — kun +informativt, ikke et hinder). + +Under selve importen krasjet koden to ganger på et reelt GolfAPI-funn: +tjenesten returnerer noen ganger en TOM STRENG (`""`) i stedet for +`null`/fraværende felt for `courseRatingMen`/`slopeMen`/ +`courseRatingWomen`/`slopeWomen` — ikke bare for kvinner, som ADR-019s +tilsvarende teeoff-antakelse forutsatte, Nesbyen manglet `slopeMen` +også. Fikset på tre nivåer: (1) `golfapi_cache.py` normaliserer nå +`""` → `None` for alle fire ratingfelt FØR insert, (2) +`golfapi_course_tee.course_rating_men`/`slope_men` gjort nullable +(migrasjon 068 — var feilaktig `NOT NULL`), (3) begge import- +endepunktene (`courses.py`s `international-import`, `rounds.py`s +personal-course-ekvivalent) bygger nå rating per utslag defensivt — +hopper over et utslag HELT (ingen `tee`/`personal_course_tee`-rad) hvis +verken herre- eller dame-rating er brukbar, og avviser HELE importen +med `EXTERNAL_DATA_INCOMPLETE` FØR transaksjonen starter hvis +bokstavelig talt intet utslag har noen brukbar rating (fail-fast, +samme ADR-019 Beslutning C-filosofi). Nesbyens 6 utslag hadde faktisk +INGEN rating i det hele tatt hos GolfAPI (ikke bare tomme strenger på +enkeltfelt) — brukeren oppga alle 6 utslags course rating/slope +(herre+dame, ett utslag uten damerating) manuelt fra klubbens eget +scorekort, satt inn direkte i `golfapi_course_tee`-cachen før import. + +**Verifisert:** `python3 -m py_compile` rent på alle tre endrede +Python-filer, full `./scripts/run_backend_tests.sh` (31/31, migrasjon +068 bekreftet anvendbar). Selve importen kjørt direkte mot ekte +`teecup_db` via et engangsskript i `teecup_api`-containeren (speiler +`import_international_personal_course`s kopier-inn-logikk nøyaktig) — +etterpå bekreftet med en read-only spørring: Larvik (18 hull, 6 utslag, +11 tee-ratinger, 95 koordinater) og Nesbyen (18 hull, 6 utslag, 11 +tee-ratinger, 124 koordinater), begge eid av Erol Haagenrud, `golfapi_ +course.has_gps`/`num_coordinates` korrigert for Nesbyen. **Rullet ut +2026-08-13** — migrasjon 067+068 kjørt mot ekte `teecup_db`, +`teecup_api` bygget/omstartet, ren oppstartslogg. + Disse må avklares før eller under de relevante fasene: 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på @@ -6265,6 +6309,102 @@ Disse må avklares før eller under de relevante fasene: --- +## ADR-065: Per-deltaker-fullføring av frittstående runder + scorekort på e-post — 2026-08-13 + +Brukerens konkrete scenario: fire spillere sammen i én runde, to går 18 +hull, én går 9, én må avslutte etter 14. Med dagens modell +(`round.completed_at`, rundeomfattende) kan runden ikke fullføres for +de ferdige spillerne uten samtidig å låse scoreføring for de som +fortsatt spiller (`readOnly = completed` var alt-eller-ingenting) — og +ingen kunne få scorekortet sitt på e-post før HELE runden var ferdig. + +Bekreftet med bruker (AskUserQuestion) og ett tillegg i etterkant: +1. **Rundens egen fullføring forblir MANUELL** — eieren trykker + fortsatt "Fullfør runde" til slutt (HCP-beregning/varsler skjer + fortsatt der, uendret). Per-deltaker-fullføring er KUN en nagging- + brems + trigger for e-post, IKKE en auto-cascade til hele rundens + fullføring. +2. **Scorekort på e-post sendes UMIDDELBART** når en spiller selv blir + ferdig, ikke samlet ved rundefullføring — mest nyttig nettopp for + spilleren som går av tidlig. +3. **Tillegg:** må også kunne sende scorekort til en enkelt spiller i + ETTERTID, lenge etter at runden allerede er fullført (glemt/tapt + e-post, ønske om å sende på nytt) — et eget on-demand-endepunkt, + ikke bundet til selve fullførings-transisjonen. + +**Bevisst utenfor omfang:** delt-ball-formater (foursome/scramble/ +greensome, som fullfører per SIDE via `round_side`, ikke per deltaker) +— brukerens eksempel er individuell slagspill. Strukturelt symmetrisk +follow-up når/hvis etterspurt. + +**Datamodell (migrasjon 066):** `round_participant.completed_at +timestamptz`, ingen CHECK mot `round.completed_at` — applikasjonslaget +garanterer rekkefølgen (deltaker-fullføring skjer alltid FØR/uavhengig +av rundens egen, aldri etter at runden er låst). + +**Tilgang — `completed` er et FLIGHT-STYRT felt, ikke eier-/selv- +only.** Speiler `update_hole`s eksisterende begrunnelse ("en lenket +medspiller kan registrere score for HELE flighten") — enhver med +tilgang til runden (eier ELLER lenket medspiller) kan avslutte/ +gjenåpne EN HVILKEN SOM HELST deltaker, akkurat som de allerede kan +føre score for en hvilken som helst deltaker. Ny +`_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}`-konstant i +`rounds.py` hopper eksplisitt over både eier-only-sjekken og +selv-only-sjekken KUN for dette feltet — uendret for alle andre felt i +samme kall. Avvist med `ALREADY_COMPLETED` (409) hvis `round. +completed_at` allerede er satt — ingen åpning/lukking av +enkeltdeltakere etter at hele runden er låst. + +**E-post — én delt funksjon, to mottakertyper.** +`_send_participant_round_summary(conn, round_id, participant_id, +round_label) -> bool` erstatter den gamle gjeste-only +`_send_guest_round_summaries` — slår nå opp BÅDE gjest (`guest_email`) +OG lenket konto (`app_user.email` via LEFT JOIN), returnerer om en +adresse fantes. Kun den FØRSTE `null → true`-transisjonen av +`completed_at` trigger e-post (idempotent — re-sending `completed: +true` når allerede sann, eller `completed: false`, sender aldri på +nytt automatisk). `complete_round` har en fallback-løkke: enhver +deltaker som fortsatt er `completed_at IS NULL` når hele runden +fullføres, får det satt nå OG e-post sendt der også — dekker det +vanlige tilfellet (ingen trykket "avslutt" underveis) uten +dobbel-sending for de som allerede var individuelt avsluttet. +`app/email.py`s `send_round_summary_email` fikk `is_linked_account`/ +`round_id`-parametre: lenkede mottakere får en direkte +`/my-rounds/{round_id}`-lenke (ingen magic-link, de har allerede +konto) og hilsen med `app_user.first_name` (navneformat-regelen i +CLAUDE.md), gjester beholder den opprinnelige magic-link-CTA-en. + +**Nytt on-demand-endepunkt** (tillegg 3 over): `POST /rounds/{round_ +id}/participants/{participant_id}/send-scorecard` — fungerer UANSETT +fullføringsstatus, også lenge etter at runden er fullført. Samme +flight-styrte tilgang som `completed`-feltet, men bevisst INGEN +`ALREADY_COMPLETED`-sperre (leser og sender kun, muterer aldri +databasetilstand). 204 ved suksess, `NO_EMAIL_ADDRESS` (400) hvis +deltakeren ikke har noen registrert adresse. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh`. Egen scratch-database: 4-spiller +frittstående slagspill-runde (blanding lenket bruker + gjest med +e-post), ulikt antall hull spilt, avsluttet to deltakere manuelt via +PATCH — bekreftet e-post trigges umiddelbart med riktig mottaker- +variant (gjest vs. lenket, ulik lenke/hilsen), `ALREADY_COMPLETED` ved +forsøk etter rundefullføring, `complete_round` sender KUN til +ikke-allerede-avsluttede deltakere, en lenket medspiller (ikke eier) +kan avslutte en ANNEN deltaker (flight-styrt tilgang bekreftet), angre +(`completed: false`) fungerer før rundens egen fullføring, og det nye +on-demand-endepunktet fungerer også lenge etter rundefullføring. +Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067 +(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13. + +**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`-felt, +"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper i +`PlayerHoleCards` og "Spillere og runde"-fanen, `allHolesEnteredFor +Everyone`-justering, ScoringWizard-spillerkjede-hopp for fullførte +deltakere) — backend er bygget og verifisert, men ikke koblet inn i UI +ennå. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 834f074..20c0235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10863,3 +10863,97 @@ Neste steg: **Rullet ut 2026-08-13**, samme økt. Ren frontend-endring -- `docker compose build teecup_frontend && up -d teecup_frontend`. Ingen konsollfeil på `https://teecup.golf/logg-inn` etter omstart. + +78. **Per-deltaker-fullføring av frittstående runder + scorekort på + e-post — 2026-08-13, se ADR-065.** Bruker: fire spillere sammen, + to går 18 hull, en 9, en må avslutte etter 14 -- disse rundene + skulle kunne markeres fullført per spiller, uten å måtte fullføre + hele runden, og uten å låse scoreføring for de andre. + + Migrasjon 066: `round_participant.completed_at timestamptz`. + Backend (`app/routers/rounds.py`): `ParticipantUpdate.completed`, + ny `_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}` -- speiler + `update_hole`s "lenket medspiller kan registrere for hele + flighten"-filosofi, IKKE eier-/selv-only. `ALREADY_COMPLETED` (409) + hvis `round.completed_at` allerede er satt. `_send_guest_round_ + summaries` erstattet av `_send_participant_round_summary(conn, + round_id, participant_id, round_label) -> bool`, som nå dekker + BÅDE gjester og lenkede kontoer (LEFT JOIN `app_user`) -- kun + første `null -> true`-transisjon trigger e-post (idempotent). + `complete_round` har en fallback-løkke som fullfører+e-poster + enhver deltaker som IKKE allerede ble individuelt avsluttet, ingen + dobbel-sending for de som var det. `app/email.py`s `send_round_ + summary_email` fikk `is_linked_account`/`round_id` -- lenkede + mottakere får direktelenke til `/my-rounds/{id}` og hilsen med + `first_name` (navneformat-regelen), gjester beholder magic-link. + + **Tillegg samme dag under samtalen:** må også kunne sende + scorekort til en enkelt spiller i ETTERTID, lenge etter at runden + er fullført. Nytt endepunkt `POST /rounds/{id}/participants/{id}/ + send-scorecard` -- samme flight-styrte tilgang, men bevisst INGEN + `ALREADY_COMPLETED`-sperre (leser/sender, muterer aldri + databasetilstand). 204 ved suksess, `NO_EMAIL_ADDRESS` (400) uten + registrert adresse. + + **Verifisert:** `python3 -m py_compile` + full + `./scripts/run_backend_tests.sh`. Egen scratch-database: 4-spiller + runde (lenket bruker + gjest med e-post), ulikt hull-antall, + avsluttet to deltakere via PATCH -- bekreftet e-post umiddelbart + med riktig mottaker-variant, `ALREADY_COMPLETED` etter + rundefullføring, fallback-løkken sender kun til ikke-avsluttede, + lenket medspiller (ikke eier) kan avslutte ANNEN deltaker + (flight-tilgang bekreftet), angre (`completed: false`) fungerer + før rundefullføring, on-demand-endepunktet fungerer også lenge + etter fullføring. + + **Rullet ut 2026-08-13**, samme økt, sammen med migrasjon 067 + (punkt 79 under). `docker compose build teecup_api && up -d`. + + **Bevisst utenfor denne runden:** frontend-UI (`Player.completed`, + "Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper, + nagging-justering, ScoringWizard-spillerkjede-hopp) -- backend + bygget og verifisert, ikke koblet inn i UI ennå. + +79. **To nye baner (Larvik/Seasidebanen, Nesbyen/Nesfjellet) via + GolfAPI + en reell datakvalitetsbug funnet under importen — + 2026-08-13, se ADR-064-tillegg.** Bruker ba om å mappe Larvik + (Seasidebanen) og Nesbyen, sistnevnte manuelt GPS-registrert (124 + feltbefarte punkter, CSV) siden GolfAPI selv mangler koordinater + for banen (`hasGPS=0`). + + To nye `poi_type`-verdier (migrasjon 067): `rock` (fjellknaus -- + hinder) og `layup` (til fairway -- kun informativt). Bruker + korrigerte terminologi underveis: "Bunker (green)" ikke "Bunker + (grønn)" (green er et låneord, ikke oversatt), og "Over vann" + skal telle som "Bakkant vann" (samme `poi_type`/`location`). + + **Reell bug funnet under import:** GolfAPI returnerer noen ganger + tom STRENG (`""`) i stedet for `null`/fraværende for course + rating/slope-felt -- ikke bare for kvinner (ADR-019s tilsvarende + antakelse for teeoff), Nesbyen manglet `slopeMen` også. Fikset på + tre nivåer: `golfapi_cache.py` normaliserer `""` -> `None` for + alle fire ratingfelt, migrasjon 068 gjør `golfapi_course_tee. + course_rating_men`/`slope_men` nullable (var feilaktig NOT NULL), + og begge import-endepunktene (`courses.py`, `rounds.py`) bygger nå + rating per utslag defensivt -- hopper over et utslag uten brukbar + rating, avviser hele importen med `EXTERNAL_DATA_INCOMPLETE` FØR + transaksjonen hvis INGEN utslag har noen brukbar rating. Nesbyens + 6 utslag hadde faktisk ingen rating i det hele tatt hos GolfAPI -- + bruker oppga alle 6 (herre+dame, ett uten damerating) manuelt fra + klubbens scorekort, satt inn i `golfapi_course_tee`-cachen før + import fullførte. + + **Verifisert:** `python3 -m py_compile` rent, full + `./scripts/run_backend_tests.sh` (31/31, migrasjon 068 bekreftet + anvendbar). Selve importen kjørt mot ekte `teecup_db` via et + engangsskript i `teecup_api`-containeren (speiler + `import_international_personal_course`s kopier-inn-logikk). + Etterpå bekreftet med read-only spørring: Larvik (18 hull, 6 + utslag, 11 tee-ratinger, 95 koordinater) og Nesbyen (18 hull, 6 + utslag, 11 tee-ratinger, 124 koordinater), begge eid av Erol + Haagenrud, `golfapi_course.has_gps`/`num_coordinates` korrigert + for Nesbyen. + + **Rullet ut 2026-08-13**, samme økt. Migrasjon 067+068 kjørt mot + ekte `teecup_db`, `docker compose build teecup_api && up -d`, ren + oppstartslogg. diff --git a/app/email.py b/app/email.py index a4017a9..b9fbd6f 100644 --- a/app/email.py +++ b/app/email.py @@ -361,12 +361,26 @@ async def send_round_summary_email( round_label: str, holes: list[RoundSummaryHole], stat_lines: list[tuple[str, str]], - raw_token: str, + raw_token: str | None, + is_linked_account: bool = False, + round_id: str | None = None, ) -> None: - """Rundeoppsummering til en midlertidig spiller (gjest) med registrert - e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round, - 2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via - _send_sync sin nye html_body-parameter, med en ekte tekst-fallback. + """Rundeoppsummering til en spiller med registrert e-post, sendt + UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin + update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele + rundens fullføring, complete_round, 2026-08-03). FØRSTE HTML-e-post i + appen -- multipart/alternative via _send_sync sin nye html_body- + parameter, med en ekte tekst-fallback. + + To mottakertyper (migrasjon 066 utvidet fra kun gjester): + - Gjest (`is_linked_account=False`): `raw_token` er et ekte magic-link- + innloggingstoken (samme mønster som tournaments.py sin + send_scorecard_invitations) -- ingen konto finnes ennå, e-posten + pitcher én. + - Lenket bruker (`is_linked_account=True`): `raw_token` er None, + `round_id` MÅ være satt -- lenken peker rett på rundesiden (vanlig + innlogging gjelder), ingen konto-pitch (feil for noen som allerede + har en). Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til mottakeren -- KUN fornavn i hilsenen, aldri fullt navn. @@ -378,9 +392,13 @@ async def send_round_summary_email( vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke finnes data for. - Alltid norsk, samme presedens som send_scorecard_invitations (gjesten - har ingen lagret språkpreferanse -- ingen konto ennå).""" - link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}" + Alltid norsk, samme presedens som send_scorecard_invitations (verken en + gjest eller en spiller midt i en runde har nødvendigvis en lagret + språkpreferanse tilgjengelig her).""" + if is_linked_account: + link = f"{settings.PUBLIC_BASE_URL}/my-rounds/{round_id}" + else: + link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}" subject = f"Scorekortet ditt fra {round_label}" text_lines = [ @@ -396,17 +414,24 @@ async def send_round_summary_email( text_lines.append("") for label, value in stat_lines: text_lines.append(f"{label}: {value}") - text_lines += [ - "", - "Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- " - "du trenger ingen konto for å se dette scorekortet, men med én kan du " - "føre score selv, følge egen statistikk og handicap-utvikling over tid.", - "", - f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):", - link, - "", - "Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.", - ] + if is_linked_account: + text_lines += [ + "", + "Se hele runden:", + link, + ] + else: + text_lines += [ + "", + "Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- " + "du trenger ingen konto for å se dette scorekortet, men med én kan du " + "føre score selv, følge egen statistikk og handicap-utvikling over tid.", + "", + f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):", + link, + "", + "Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.", + ] body = "\n".join(text_lines) hole_rows_html = "\n".join( @@ -421,6 +446,21 @@ async def send_round_summary_email( f'
+Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen +konto for å se dette scorekortet, men med én kan du føre score selv, følge egen +statistikk og handicap-utvikling over tid. +
""" + cta_label = "Logg inn og se hele runden" + footer_html = f"""+Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne +e-posten -- den brukes ikke til noe annet. +
""" html_body = f"""\ @@ -443,19 +483,12 @@ async def send_round_summary_email(-Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen -konto for å se dette scorekortet, men med én kan du føre score selv, følge egen -statistikk og handicap-utvikling over tid. -
+{pitch_html}| Logg inn og se hele runden +font-weight:700;text-decoration:none;">{cta_label} |
-Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne -e-posten -- den brukes ikke til noe annet. -
+{footer_html} diff --git a/app/golfapi_cache.py b/app/golfapi_cache.py index ec70750..b194d39 100644 --- a/app/golfapi_cache.py +++ b/app/golfapi_cache.py @@ -70,6 +70,21 @@ async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None: for t in course.get("tees", []): lengths = [t.get(f"length{n}") for n in range(1, num_holes + 1)] + # Reelt funn (Larvik/Nesbyen, 2026-08-13): GolfAPI returnerer + # tomme STRENGER ("") for manglende rating-felt på enkelte + # baner -- IKKE bare for kvinner (opprinnelig antakelse, speilet + # fra ADR-019s teeoff-import), Nesbyen manglet `slopeMen` også. + # `.get(...) is not None` alene lot int()/numeric-kolonnene + # krasje på disse. Normaliser "" -> None FØR bruk, for BEGGE + # kjønn (migrasjon 068 gjorde course_rating_men/slope_men + # nullable av samme grunn -- kolonnene tåler nå det cachen her + # faktisk kan produsere). + def _num(key: str): + v = t.get(key) + return None if v is None or v == "" else v + + slope_men = _num("slopeMen") + slope_women = _num("slopeWomen") await conn.execute( """ INSERT INTO golfapi_course_tee @@ -84,14 +99,14 @@ async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None: t.get("teeName") or "Tee", t.get("teeColor"), lengths, - t["courseRatingMen"], - int(t["slopeMen"]), - t.get("courseRatingWomen"), - int(t["slopeWomen"]) if t.get("slopeWomen") is not None else None, - t.get("courseRatingMenFront9"), - t.get("courseRatingMenBack9"), - t.get("courseRatingWomenFront9"), - t.get("courseRatingWomenBack9"), + _num("courseRatingMen"), + int(slope_men) if slope_men is not None else None, + _num("courseRatingWomen"), + int(slope_women) if slope_women is not None else None, + _num("courseRatingMenFront9"), + _num("courseRatingMenBack9"), + _num("courseRatingWomenFront9"), + _num("courseRatingWomenBack9"), ) for c in coordinates: diff --git a/app/routers/courses.py b/app/routers/courses.py index 7d7a4a0..507aa69 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -716,6 +716,14 @@ async def import_international_course( raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.") if not tees: raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.") + if not any( + (t["course_rating_men"] is not None and t["slope_men"] is not None) + or (t["course_rating_women"] is not None and t["slope_women"] is not None) + for t in tees + ): + raise app_error( + 400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI." + ) par_total = sum(h["par_men"] for h in holes) course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"] @@ -739,13 +747,22 @@ async def import_international_course( ) for t in tees: + # Reelt funn (Nesbyen, 2026-08-13): GolfAPI kan mangle RATING + # FOR BEGGE kjønn på et utslag (opprinnelig antatt at menns alltid + # finnes, speilet fra ADR-019s teeoff-import) -- begge sjekkes nå + # individuelt, samme mønster som teeoff-importen allerede bruker. + # Utslag uten NOEN brukbar rating hoppes over (usable_tees under). + ratings: list[tuple[str, float, int]] = [] + if t["course_rating_men"] is not None and t["slope_men"] is not None: + ratings.append(("m", float(t["course_rating_men"]), int(t["slope_men"]))) + if t["course_rating_women"] is not None and t["slope_women"] is not None: + ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]))) + if not ratings: + continue tee_row = await conn.fetchrow( "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id", organization_id, course_id, t["name"], ) - ratings: list[tuple[str, float, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]))] - if t["course_rating_women"] is not None and t["slope_women"] is not None: - ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]))) for gender, course_rating, slope_rating in ratings: await conn.execute( """ diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 95adc74..908cdfe 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -321,6 +321,14 @@ async def import_international_personal_course( raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.") if not tees: raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.") + if not any( + (t["course_rating_men"] is not None and t["slope_men"] is not None) + or (t["course_rating_women"] is not None and t["slope_women"] is not None) + for t in tees + ): + raise app_error( + 400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI." + ) par_total = sum(h["par_men"] for h in holes) course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"] @@ -342,13 +350,20 @@ async def import_international_personal_course( course_row["id"], h["hole_number"], h["par_men"], h["index_men"], ) for t in tees: + # Reelt funn (Nesbyen, 2026-08-13): se tilsvarende kommentar i + # courses.py sin international-import -- begge kjønn sjekkes + # individuelt, utslag uten NOEN brukbar rating hoppes over. + ratings: list[tuple[str, float, int, int]] = [] + if t["course_rating_men"] is not None and t["slope_men"] is not None: + ratings.append(("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)) + if t["course_rating_women"] is not None and t["slope_women"] is not None: + ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total)) + if not ratings: + continue tee_row = await conn.fetchrow( "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id", course_row["id"], t["name"], ) - ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)] - if t["course_rating_women"] is not None and t["slope_women"] is not None: - ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total)) for gender, course_rating, slope_rating, par in ratings: await conn.execute( """ @@ -1376,6 +1391,12 @@ class RoundParticipantOut(BaseModel): playing_handicap: int | None # Money Ball (2026-07-30) -- 0-3, null for alle andre formater. lineup_order: int | None + # Per-deltaker avslutning (migrasjon 066) -- UAVHENGIG av round.completed_at + # (som forblir manuell/rundeomfattende). Sann etter PATCH {completed: true} + # (se update_participant) ELLER etter complete_round sin fallback for + # deltakere som aldri ble individuelt avsluttet. Brukes til å stoppe + # "mangler score"-nagging for DENNE spilleren uten å låse resten av flighten. + completed: bool class RoundSideOut(BaseModel): @@ -1474,7 +1495,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: rp.course_handicap_snapshot, rp.counts_for_handicap, rp.score_differential::float AS score_differential, rp.stat_level, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, - rp.playing_handicap, rp.lineup_order + rp.playing_handicap, rp.lineup_order, (rp.completed_at IS NOT NULL) AS completed FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at @@ -2982,7 +3003,7 @@ async def add_participant( rp.course_handicap_snapshot, rp.counts_for_handicap, rp.score_differential::float AS score_differential, rp.stat_level, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, - rp.playing_handicap, rp.lineup_order + rp.playing_handicap, rp.lineup_order, (rp.completed_at IS NOT NULL) AS completed FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.id = $1 @@ -3195,6 +3216,12 @@ class ParticipantUpdate(BaseModel): # 'money_ball'. 0-3, unik per runde -- avgjør rotasjonsrekkefølgen # ((posisjon-1) % 4) for hvem som er "money ball" på hvilket hull. lineup_order: int | None = Field(default=None, ge=0, le=3) + # Per-deltaker avslutning (migrasjon 066) -- FLIGHT-STYRT, ikke + # eier-only/selv-only som resten av feltene her (se + # _FLIGHT_MANAGED_FIELDS/update_participant): en lenket medspiller kan + # avslutte EN HVILKEN SOM HELST deltaker, samme begrunnelse som at de + # allerede kan føre score for hele flighten (update_hole). + completed: bool | None = None # Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder -- @@ -3204,6 +3231,13 @@ _OWNER_ONLY_PARTICIPANT_FIELDS = { "round_side_id", "lineup_order", } +# Motsatt av _OWNER_ONLY_PARTICIPANT_FIELDS over -- IKKE begrenset til +# eieren ELLER egen rad. Samme begrunnelse som update_hole (linje ~5375): +# en lenket medspiller registrerer allerede score for HELE flighten, så +# å avslutte en ANNEN deltaker er samme klasse handling, ikke en sensitiv +# profil-endring. +_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"} + @router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut) async def update_participant( @@ -3225,9 +3259,11 @@ async def update_participant( SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.counts_for_handicap, rp.guest_first_name, rp.guest_last_name, + rp.completed_at AS participant_completed_at, r.owner_user_id::text AS round_owner_user_id, r.completed_at, r.course_source, r.teeoff_facility_slug, - r.teeoff_course_id, r.personal_course_id, r.play_format + r.teeoff_course_id, r.personal_course_id, r.play_format, r.name AS round_name, + r.course_name_snapshot FROM round_participant rp JOIN round r ON r.id = rp.round_id WHERE rp.id = $1 AND rp.round_id = $2 @@ -3244,7 +3280,8 @@ async def update_participant( 403, "NOT_AUTHORIZED", "Du kan kun endre om DIN egen deltakelse skal telle mot ditt faktiske HCP her.", ) - if current["user_id"] != user.user_id: + non_flight_fields = updates.keys() - _FLIGHT_MANAGED_PARTICIPANT_FIELDS + if non_flight_fields and current["user_id"] != user.user_id: raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.") guest_only_fields = {"guest_first_name", "guest_last_name", "gender", "guest_email"} @@ -3261,6 +3298,18 @@ async def update_participant( "Runden er allerede fullført -- kan ikke endre utslag, HCP eller kjønn i etterkant.", ) + # Per-deltaker avslutning (migrasjon 066) -- kan verken settes ELLER + # angres etter at RUNDEN som helhet er fullført (HCP-differensialer + # er da allerede beregnet og frosset for alle deltakere samlet). + newly_completed = False + if "completed" in updates: + if current["completed_at"] is not None: + raise app_error( + 409, "ALREADY_COMPLETED", + "Runden er allerede fullført -- kan ikke avslutte/gjenåpne en enkeltdeltaker i etterkant.", + ) + newly_completed = updates["completed"] and current["participant_completed_at"] is None + set_clauses: list[str] = [] values: list[object] = [] @@ -3325,6 +3374,10 @@ async def update_participant( values.append(new_lineup_order) set_clauses.append(f"lineup_order = ${len(values)}") + if "completed" in updates: + values.append(datetime.now(timezone.utc) if updates["completed"] else None) + set_clauses.append(f"completed_at = ${len(values)}") + if rating_changed: if current["course_source"] == "teeoff": resolved = await _resolve_teeoff_course( @@ -3418,7 +3471,7 @@ async def update_participant( rp.course_handicap_snapshot, rp.counts_for_handicap, rp.score_differential::float AS score_differential, rp.stat_level, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, - rp.playing_handicap, rp.lineup_order + rp.playing_handicap, rp.lineup_order, (rp.completed_at IS NOT NULL) AS completed FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.id = $1 @@ -3435,6 +3488,14 @@ async def update_participant( and current["completed_at"] is not None ): await _recompute_computed_handicap_index(conn, current["user_id"]) + + # Scorekort på e-post UMIDDELBART når DENNE spilleren blir ferdig + # (bekreftet med bruker 2026-08-13) -- ikke samlet ved rundens egen + # fullføring (complete_round har sin egen fallback for deltakere + # som ALDRI ble individuelt avsluttet, se der). + if newly_completed: + round_label = (current["round_name"] or "").strip() or current["course_name_snapshot"] + await _send_participant_round_summary(conn, round_id, participant_id, round_label) await broadcast_round_update(round_id) return RoundParticipantOut(**dict(row)) @@ -5811,7 +5872,7 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ SELECT id::text AS id, user_id::text AS user_id, handicap_index_snapshot::float AS handicap_index_snapshot, course_rating_snapshot::float AS course_rating_snapshot, - slope_rating_snapshot, tee_par_snapshot + slope_rating_snapshot, tee_par_snapshot, completed_at FROM round_participant WHERE round_id = $1 """, round_id, @@ -5848,6 +5909,20 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id) + # Per-deltaker avslutning (migrasjon 066) -- fallback for deltakere + # som ALDRI ble individuelt avsluttet underveis (det vanlige + # tilfellet: alle spilte alle hull, ingen trykket "avslutt for meg" + # på egen hånd). Setter completed_at + sender scorekort-e-post NÅ + # for dem -- deltakere som ALLEREDE ble avsluttet+e-postet tidligere + # (completed_at allerede satt) røres ikke, ingen dobbel-sending. + round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"] + for p in participants: + if p["completed_at"] is None: + await conn.execute( + "UPDATE round_participant SET completed_at = now() WHERE id = $1", p["id"] + ) + await _send_participant_round_summary(conn, round_id, p["id"], round_label) + # Faktisk HCP (ADR-038) -- alle differensialer for runden er nå # skrevet, regn på nytt for hver deltaker med egen konto (gjester # har ingen computed_handicap_index å oppdatere). @@ -5860,7 +5935,6 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ # 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} @@ -5878,75 +5952,103 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}", ) - await _send_guest_round_summaries(conn, round_id, round_label) - result = await _load_round_out(conn, round_id, user.user_id) await broadcast_round_update(round_id) return result -async def _send_guest_round_summaries(conn, round_id: str, round_label: str) -> None: - """Rundeoppsummering til midlertidige spillere med registrert e-post - (2026-08-03) -- ETT ekte magic-link-innloggingstoken per gjest, samme - mønster som tournaments.py sin send_scorecard_invitations. Graderer seg - naturlig etter individuell-ball (egne slag/putter) vs. delt-ball - (kun sidens felles slag, ADR-039 Beslutning C) -- ingen egen - format-sjekk trengs, feltene som ikke finnes er rett og slett fraværende - i kildedataene.""" - guest_rows = await conn.fetch( +async def _send_participant_round_summary(conn, round_id: str, participant_id: str, round_label: str) -> bool: + """Scorekort på e-post til ÉN deltaker. Returnerer True hvis en adresse + fantes og sending faktisk ble forsøkt, False hvis deltakeren ikke har + noen registrert e-post (kalleren kan bruke dette til å gi tydelig + tilbakemelding ved en EKSPLISITT send-handling, se + send_participant_scorecard under -- de to andre kallerne, update_participant + og complete_round, ignorerer returverdien, samme "stille hvis ingen + adresse"-oppførsel som før 2026-08-13). + + Tre kallere (migrasjon 066, 2026-08-13): + 1. UMIDDELBART når DENNE deltakeren avsluttes (update_participant). + 2. complete_round sin fallback for deltakere som aldri ble individuelt + avsluttet. + 3. `POST .../participants/{id}/send-scorecard` -- eksplisitt, PÅ NYTT- + sending når som helst, ogsÅ for en runde som ble fullført for lenge + siden (bekreftet med bruker: må kunne gå inn i tidligere fullførte + runder og sende scorekort til enkeltspillere). + + Dekker BÅDE gjester (guest_email) OG lenkede brukere (app_user.email) -- + opprinnelig (2026-08-03) kun gjester. Graderer seg naturlig etter + individuell-ball (egne slag/putter) vs. delt-ball (kun sidens felles + slag, ADR-039 Beslutning C) -- ingen egen format-sjekk trengs, feltene + som ikke finnes er rett og slett fraværende i kildedataene.""" + g = await conn.fetchrow( """ - SELECT id::text AS id, guest_email, guest_first_name, round_side_id::text AS round_side_id, stat_level - FROM round_participant - WHERE round_id = $1 AND guest_email IS NOT NULL + SELECT rp.id::text AS id, rp.guest_email, rp.guest_first_name, + rp.round_side_id::text AS round_side_id, rp.stat_level, + rp.user_id::text AS user_id, au.email::text AS account_email, au.first_name AS account_first_name + FROM round_participant rp + LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.id = $1 AND rp.round_id = $2 """, - round_id, + participant_id, round_id, ) - for g in guest_rows: - stat_lines: list[tuple[str, str]] = [] - if g["round_side_id"] is not None: - side_holes = await _build_side_holes(conn, round_id, g["round_side_id"]) - summary_holes = [ - RoundSummaryHole( - hole_number=h.hole_number, - par=h.par, - score=h.score if h.played else None, - net=h.score - h.strokes_received - if h.played and h.score is not None and h.strokes_received is not None - else None, - ) - for h in side_holes - ] - else: - p_holes = await _build_participant_holes(conn, round_id, g["id"]) - summary_holes = [ - RoundSummaryHole( - hole_number=h.hole_number, - par=h.par, - score=h.score if h.played else None, - net=h.score - h.strokes_received - if h.played and h.score is not None and h.strokes_received is not None - else None, - ) - for h in p_holes - ] - if g["stat_level"] != "strokes_only": - total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None) - if total_putts: - stat_lines.append(("Putter totalt", str(total_putts))) + if g is None: + return False + is_linked = g["user_id"] is not None + email = (g["account_email"] if is_linked else g["guest_email"]) or None + if email is None: + return False # ingen registrert adresse -- ingenting å sende (vanligste tilfellet) + first_name = (g["account_first_name"] if is_linked else g["guest_first_name"]) or ("Der" if is_linked else "Gjest") - played_holes = [h for h in summary_holes if h.score is not None] - if played_holes: - total_score = sum(h.score for h in played_holes) - to_par = total_score - sum(h.par for h in played_holes) - lead_lines = [("Slag totalt", str(total_score)), ("Til par", _signed(to_par))] - net_holes = [h for h in played_holes if h.net is not None] - if net_holes: - net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes) - lead_lines.append(("Netto til par", _signed(net_to_par))) - stat_lines = lead_lines + stat_lines + stat_lines: list[tuple[str, str]] = [] + if g["round_side_id"] is not None: + side_holes = await _build_side_holes(conn, round_id, g["round_side_id"]) + summary_holes = [ + RoundSummaryHole( + hole_number=h.hole_number, + par=h.par, + score=h.score if h.played else None, + net=h.score - h.strokes_received + if h.played and h.score is not None and h.strokes_received is not None + else None, + ) + for h in side_holes + ] + else: + p_holes = await _build_participant_holes(conn, round_id, g["id"]) + summary_holes = [ + RoundSummaryHole( + hole_number=h.hole_number, + par=h.par, + score=h.score if h.played else None, + net=h.score - h.strokes_received + if h.played and h.score is not None and h.strokes_received is not None + else None, + ) + for h in p_holes + ] + if g["stat_level"] != "strokes_only": + total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None) + if total_putts: + stat_lines.append(("Putter totalt", str(total_putts))) - email = g["guest_email"].strip().lower() + played_holes = [h for h in summary_holes if h.score is not None] + if played_holes: + total_score = sum(h.score for h in played_holes) + to_par = total_score - sum(h.par for h in played_holes) + lead_lines = [("Slag totalt", str(total_score)), ("Til par", _signed(to_par))] + net_holes = [h for h in played_holes if h.net is not None] + if net_holes: + net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes) + lead_lines.append(("Netto til par", _signed(net_to_par))) + stat_lines = lead_lines + stat_lines + + email = email.strip().lower() + raw_token: str | None = None + if not is_linked: + # Magic-link kun for gjester -- en lenket bruker har allerede en + # konto og logger inn som vanlig, se send_round_summary_email sin + # is_linked_account-gren. raw_token = secrets.token_urlsafe(32) expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) # Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som @@ -5958,18 +6060,56 @@ async def _send_guest_round_summaries(conn, round_id: str, round_label: str) -> "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')", email, _hash_secret(raw_token), expires_at, ) - if settings.SMTP_CONFIGURED: - try: - await send_round_summary_email( - email, g["guest_first_name"] or "Gjest", round_label, summary_holes, stat_lines, raw_token, - ) - except Exception: - # Se app/email.py sitt mønster -- en driftsfeil i selve - # utsendingen skal aldri hindre fullføringen av runden. - traceback.print_exc() - elif settings.DEV_LOG_MAGIC_LINKS: + if settings.SMTP_CONFIGURED: + try: + await send_round_summary_email( + email, first_name, round_label, summary_holes, stat_lines, raw_token, + is_linked_account=is_linked, round_id=round_id, + ) + except Exception: + # Se app/email.py sitt mønster -- en driftsfeil i selve + # utsendingen skal aldri hindre fullføringen/avslutningen. + traceback.print_exc() + elif settings.DEV_LOG_MAGIC_LINKS: + if raw_token: print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True) - print(f"[DEV] Rundeoppsummering til {email}: {round_label}, {len(played_holes)} hull spilt", flush=True) + print(f"[DEV] Rundeoppsummering til {email}: {round_label}, {len(played_holes)} hull spilt", flush=True) + return True + + +@router.post("/rounds/{round_id}/participants/{participant_id}/send-scorecard", status_code=204) +async def send_participant_scorecard( + round_id: str, + participant_id: str, + user: CurrentUser = Depends(get_current_user), +) -> None: + """Eksplisitt, på nytt-sending av scorekort-e-post -- NÅR SOM HELST, + også for en runde som ble fullført for lenge siden (bekreftet med + bruker 2026-08-13: må kunne gå inn i tidligere fullførte runder og + sende scorekort til enkeltspillere). Ingen ALREADY_COMPLETED-sperre + her (den gjelder kun selve completed-TOGGELEN, se update_participant) + -- denne handlingen endrer ingenting i databasen, kun en e-post ut. + Samme flight-styrte tilgang som update_participant sin `completed`-gren + (owner ELLER en hvilken som helst lenket medspiller kan sende for en + hvilken som helst deltaker, ikke bare seg selv).""" + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + round_row = await conn.fetchrow( + "SELECT name, course_name_snapshot FROM round WHERE id = $1", round_id + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + participant_exists = await conn.fetchval( + "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id + ) + if not participant_exists: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"] + sent = await _send_participant_round_summary(conn, round_id, participant_id, round_label) + if not sent: + raise app_error( + 400, "NO_EMAIL_ADDRESS", "Denne spilleren har ingen registrert e-postadresse å sende til." + ) def _signed(value: int) -> str: diff --git a/frontend/components/hole-target-distance.tsx b/frontend/components/hole-target-distance.tsx index 32bc639..336a53e 100644 --- a/frontend/components/hole-target-distance.tsx +++ b/frontend/components/hole-target-distance.tsx @@ -37,9 +37,10 @@ type ApiTargetPoint = { } const _HAZARD_LABELS: Record